fraim-framework 2.0.177 → 2.0.180
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 -2
- package/dist/src/ai-hub/server.js +50 -1
- package/dist/src/api/admin/payments.js +33 -0
- package/dist/src/api/admin/sales-leads.js +21 -0
- package/dist/src/api/payment/create-session.js +338 -0
- package/dist/src/api/payment/dashboard-link.js +149 -0
- package/dist/src/api/payment/session-details.js +31 -0
- package/dist/src/api/payment/webhook.js +587 -0
- package/dist/src/api/personas/me.js +29 -0
- package/dist/src/api/pricing/get-config.js +25 -0
- package/dist/src/api/sales/contact.js +44 -0
- package/dist/src/cli/commands/add-provider.js +74 -61
- package/dist/src/cli/commands/add-surface.js +128 -0
- package/dist/src/cli/commands/login.js +5 -69
- package/dist/src/cli/commands/setup.js +27 -347
- package/dist/src/cli/distribution/marketplace-bundles.js +580 -0
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/mcp/ide-formats.js +5 -3
- package/dist/src/cli/mcp/mcp-server-registry.js +10 -3
- package/dist/src/cli/providers/local-provider-registry.js +2 -3
- package/dist/src/cli/setup/auto-mcp-setup.js +9 -32
- package/dist/src/cli/setup/ide-detector.js +34 -14
- package/dist/src/config/persona-capability-bundles.js +17 -13
- package/dist/src/db/payment-repository.js +61 -0
- package/dist/src/first-run/session-service.js +2 -2
- package/dist/src/fraim/config-loader.js +11 -0
- package/dist/src/fraim/db-service.js +2387 -0
- package/dist/src/fraim/issues.js +152 -0
- package/dist/src/fraim/template-processor.js +184 -0
- package/dist/src/fraim/utils/request-utils.js +23 -0
- package/dist/src/local-mcp-server/stdio-server.js +28 -4
- package/dist/src/local-mcp-server/usage-collector.js +24 -0
- package/dist/src/middleware/auth.js +266 -0
- package/dist/src/middleware/cors-config.js +111 -0
- package/dist/src/middleware/logger.js +116 -0
- package/dist/src/middleware/rate-limit.js +110 -0
- package/dist/src/middleware/reject-query-api-key.js +45 -0
- package/dist/src/middleware/security-headers.js +41 -0
- package/dist/src/middleware/telemetry.js +134 -0
- package/dist/src/models/payment.js +2 -0
- package/dist/src/routes/analytics.js +1447 -0
- package/dist/src/routes/app-routes.js +32 -0
- package/dist/src/routes/auth-routes.js +505 -0
- package/dist/src/routes/oauth-routes.js +325 -0
- package/dist/src/routes/payment-routes.js +186 -0
- package/dist/src/routes/persona-catalog-routes.js +84 -0
- package/dist/src/services/admin-service.js +229 -0
- package/dist/src/services/audit-log-persistence.js +60 -0
- package/dist/src/services/audit-log.js +69 -0
- package/dist/src/services/cookie-service.js +129 -0
- package/dist/src/services/dashboard-access.js +27 -0
- package/dist/src/services/demo-seed-service.js +139 -0
- package/dist/src/services/email-code.js +23 -0
- package/dist/src/services/email-service-clean.js +782 -0
- package/dist/src/services/email-service.js +951 -0
- package/dist/src/services/installer-service.js +131 -0
- package/dist/src/services/mcp-oauth-store.js +33 -0
- package/dist/src/services/mcp-service.js +823 -0
- package/dist/src/services/oauth-helpers.js +127 -0
- package/dist/src/services/org-service.js +89 -0
- package/dist/src/services/persona-entitlement-service.js +288 -0
- package/dist/src/services/provider-service.js +215 -0
- package/dist/src/services/registry-service.js +628 -0
- package/dist/src/services/session-service.js +86 -0
- package/dist/src/services/trial-reminder-service.js +120 -0
- package/dist/src/services/usage-analytics-service.js +419 -0
- package/dist/src/services/workspace-identity.js +21 -0
- package/dist/src/types/analytics.js +2 -0
- package/dist/src/utils/payment-calculator.js +52 -0
- package/extensions/office-word/favicon.ico +0 -0
- package/extensions/office-word/icon-64.png +0 -0
- package/extensions/office-word/manifest.xml +33 -0
- package/extensions/office-word/taskpane.html +242 -0
- package/package.json +14 -3
- package/public/ai-hub/index.html +14 -2
- package/public/ai-hub/script.js +340 -66
- package/public/ai-hub/styles.css +83 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SessionManager = void 0;
|
|
4
|
+
class SessionManager {
|
|
5
|
+
constructor(dbService) {
|
|
6
|
+
this.sessions = new Map();
|
|
7
|
+
this.dbService = dbService;
|
|
8
|
+
// Default 1m, configurable for testing
|
|
9
|
+
this.FLUSH_INTERVAL_MS = process.env.FRAIM_TELEMETRY_FLUSH_INTERVAL
|
|
10
|
+
? parseInt(process.env.FRAIM_TELEMETRY_FLUSH_INTERVAL)
|
|
11
|
+
: 60 * 1000;
|
|
12
|
+
// Flush on shutdown
|
|
13
|
+
const cleanup = async () => {
|
|
14
|
+
console.log('Flushing telemetry sessions before shutdown...');
|
|
15
|
+
// Race flush with a 2s timeout to guarantee exit
|
|
16
|
+
const timeout = new Promise(resolve => setTimeout(resolve, 2000));
|
|
17
|
+
await Promise.race([this.flushAll(), timeout]);
|
|
18
|
+
console.log('Shutdown flush complete (or timed out). Exiting.');
|
|
19
|
+
process.exit(0);
|
|
20
|
+
};
|
|
21
|
+
process.on('SIGTERM', cleanup);
|
|
22
|
+
process.on('SIGINT', cleanup);
|
|
23
|
+
}
|
|
24
|
+
registerSession(apiKey, sessionId) {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
this.sessions.set(this.getSessionKey(apiKey, sessionId), {
|
|
27
|
+
sessionId,
|
|
28
|
+
lastWrite: now,
|
|
29
|
+
lastActive: now
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async updateActivity(apiKey, sessionId) {
|
|
33
|
+
let sessionKey = sessionId ? this.getSessionKey(apiKey, sessionId) : null;
|
|
34
|
+
let session = sessionKey ? this.sessions.get(sessionKey) : null;
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
if (!session) {
|
|
37
|
+
// Re-hydration: find the exact session when sessionId is provided.
|
|
38
|
+
// Legacy fallback (no sessionId) resolves latest active session for API key.
|
|
39
|
+
try {
|
|
40
|
+
const dbSession = sessionId
|
|
41
|
+
? await this.dbService.getSessionByApiKeyAndSessionId(apiKey, sessionId)
|
|
42
|
+
: await this.dbService.getActiveSessionByApiKey(apiKey);
|
|
43
|
+
if (dbSession) {
|
|
44
|
+
sessionKey = this.getSessionKey(apiKey, dbSession.sessionId);
|
|
45
|
+
console.log(`Re-hydrating session for API Key: ${apiKey}, sessionId: ${dbSession.sessionId}`);
|
|
46
|
+
session = {
|
|
47
|
+
sessionId: dbSession.sessionId,
|
|
48
|
+
lastWrite: now,
|
|
49
|
+
lastActive: now
|
|
50
|
+
};
|
|
51
|
+
this.sessions.set(sessionKey, session);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
console.error('Failed to re-hydrate session from DB:', e);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
session.lastActive = now;
|
|
63
|
+
const shouldFlushByInterval = now - session.lastWrite > this.FLUSH_INTERVAL_MS;
|
|
64
|
+
// Write-behind with deterministic fallback persistence:
|
|
65
|
+
// - Flush asynchronously on interval.
|
|
66
|
+
if (shouldFlushByInterval) {
|
|
67
|
+
const flushPromise = this.dbService.updateSessionActivity(session.sessionId, new Date(now));
|
|
68
|
+
flushPromise.catch((e) => {
|
|
69
|
+
console.error('Failed to flush session activity:', e);
|
|
70
|
+
});
|
|
71
|
+
session.lastWrite = now;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
async flushAll() {
|
|
76
|
+
const promises = [];
|
|
77
|
+
for (const [_, data] of this.sessions) {
|
|
78
|
+
promises.push(this.dbService.updateSessionActivity(data.sessionId, new Date(data.lastActive)));
|
|
79
|
+
}
|
|
80
|
+
await Promise.allSettled(promises);
|
|
81
|
+
}
|
|
82
|
+
getSessionKey(apiKey, sessionId) {
|
|
83
|
+
return `${apiKey}::${sessionId}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
exports.SessionManager = SessionManager;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TrialReminderService = void 0;
|
|
4
|
+
const email_service_1 = require("./email-service");
|
|
5
|
+
/**
|
|
6
|
+
* Trial Reminder Service
|
|
7
|
+
*
|
|
8
|
+
* Sends automated emails to trial users:
|
|
9
|
+
* - Day 7: Mid-trial check-in (7 days remaining)
|
|
10
|
+
* - Day 12: Trial expiring soon (2 days remaining)
|
|
11
|
+
* - Day 14: Trial expired
|
|
12
|
+
*
|
|
13
|
+
* This service should be run as a cron job once per day
|
|
14
|
+
*/
|
|
15
|
+
class TrialReminderService {
|
|
16
|
+
constructor(dbService) {
|
|
17
|
+
this.dbService = dbService;
|
|
18
|
+
this.emailService = new email_service_1.EmailService();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Main cron job entry point
|
|
22
|
+
* Run this once per day (e.g., at 10:00 AM UTC)
|
|
23
|
+
*/
|
|
24
|
+
async runDailyReminders() {
|
|
25
|
+
console.log('[TRIAL-REMINDER] Starting daily trial reminder check...');
|
|
26
|
+
try {
|
|
27
|
+
const now = new Date();
|
|
28
|
+
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
29
|
+
// Get all active trial keys
|
|
30
|
+
const allKeys = await this.dbService.listApiKeys();
|
|
31
|
+
const trialKeys = allKeys.filter(key => key.tier === 'trial' &&
|
|
32
|
+
key.status === 'active' &&
|
|
33
|
+
key.expiresAt !== null);
|
|
34
|
+
console.log(`[TRIAL-REMINDER] Found ${trialKeys.length} active trial users`);
|
|
35
|
+
let day7Count = 0;
|
|
36
|
+
let day12Count = 0;
|
|
37
|
+
let expiredCount = 0;
|
|
38
|
+
for (const key of trialKeys) {
|
|
39
|
+
const expiresAt = key.expiresAt;
|
|
40
|
+
const msUntilExpiry = expiresAt.getTime() - now.getTime();
|
|
41
|
+
const daysUntilExpiry = Math.ceil(msUntilExpiry / oneDayMs);
|
|
42
|
+
try {
|
|
43
|
+
// Trial expired
|
|
44
|
+
if (daysUntilExpiry <= 0) {
|
|
45
|
+
await this.handleExpiredTrial(key);
|
|
46
|
+
expiredCount++;
|
|
47
|
+
}
|
|
48
|
+
// 2 days remaining (day 12 of trial)
|
|
49
|
+
else if (daysUntilExpiry === 2) {
|
|
50
|
+
await this.handleTrialExpiringSoon(key, daysUntilExpiry);
|
|
51
|
+
day12Count++;
|
|
52
|
+
}
|
|
53
|
+
// 7 days remaining (day 7 of trial)
|
|
54
|
+
else if (daysUntilExpiry === 7) {
|
|
55
|
+
await this.handleMidTrialCheckIn(key);
|
|
56
|
+
day7Count++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
console.error(`[TRIAL-REMINDER] Error processing trial for ${key.userId}:`, error);
|
|
61
|
+
// Continue processing other users
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
console.log(`[TRIAL-REMINDER] Completed daily reminders:`, {
|
|
65
|
+
day7CheckIns: day7Count,
|
|
66
|
+
day12Warnings: day12Count,
|
|
67
|
+
expired: expiredCount,
|
|
68
|
+
total: trialKeys.length
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
console.error('[TRIAL-REMINDER] Error in daily reminder job:', error);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Send mid-trial check-in (day 7 - 7 days remaining)
|
|
78
|
+
*/
|
|
79
|
+
async handleMidTrialCheckIn(key) {
|
|
80
|
+
console.log(`[TRIAL-REMINDER] Sending day 7 check-in to ${key.userId}`);
|
|
81
|
+
await this.emailService.sendTrialCheckIn(key.userId, key.expiresAt);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Send trial expiring soon warning (day 12 - 2 days remaining)
|
|
85
|
+
*/
|
|
86
|
+
async handleTrialExpiringSoon(key, daysRemaining) {
|
|
87
|
+
console.log(`[TRIAL-REMINDER] Sending expiring warning to ${key.userId} (${daysRemaining} days left)`);
|
|
88
|
+
await this.emailService.sendTrialExpiring(key.userId, key.expiresAt, daysRemaining);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Handle expired trial (mark as expired, send notification)
|
|
92
|
+
*/
|
|
93
|
+
async handleExpiredTrial(key) {
|
|
94
|
+
console.log(`[TRIAL-REMINDER] Processing expired trial for ${key.userId}`);
|
|
95
|
+
// Update key status to expired
|
|
96
|
+
await this.dbService.updateApiKey(key.key, {
|
|
97
|
+
status: 'expired'
|
|
98
|
+
});
|
|
99
|
+
// Send expired notification
|
|
100
|
+
await this.emailService.sendTrialExpired(key.userId, key.expiresAt);
|
|
101
|
+
console.log(`[TRIAL-REMINDER] Trial expired and notification sent to ${key.userId}`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Manual trigger for testing (processes all trials regardless of timing)
|
|
105
|
+
*/
|
|
106
|
+
async runTestMode() {
|
|
107
|
+
console.log('[TRIAL-REMINDER] Running in TEST MODE...');
|
|
108
|
+
const allKeys = await this.dbService.listApiKeys();
|
|
109
|
+
const trialKeys = allKeys.filter(key => key.tier === 'trial' &&
|
|
110
|
+
key.expiresAt !== null);
|
|
111
|
+
console.log(`[TRIAL-REMINDER] Found ${trialKeys.length} trial users (all statuses)`);
|
|
112
|
+
for (const key of trialKeys) {
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const msUntilExpiry = key.expiresAt.getTime() - now.getTime();
|
|
115
|
+
const daysUntilExpiry = Math.ceil(msUntilExpiry / (24 * 60 * 60 * 1000));
|
|
116
|
+
console.log(`[TRIAL-REMINDER] ${key.userId}: ${daysUntilExpiry} days remaining, status: ${key.status}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
exports.TrialReminderService = TrialReminderService;
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.UsageAnalyticsService = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
/**
|
|
40
|
+
* Service for handling usage analytics operations
|
|
41
|
+
* Provides high-level interface for tracking and analyzing usage patterns
|
|
42
|
+
*/
|
|
43
|
+
class UsageAnalyticsService {
|
|
44
|
+
constructor(dbService) {
|
|
45
|
+
this.eventQueue = [];
|
|
46
|
+
this.flushInterval = null;
|
|
47
|
+
this.BATCH_SIZE = 100;
|
|
48
|
+
this.FLUSH_INTERVAL_MS = 5000; // 5 seconds
|
|
49
|
+
this.jobMap = null;
|
|
50
|
+
this.registryPath = null;
|
|
51
|
+
this.dbService = dbService;
|
|
52
|
+
this.startBatchProcessor();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Log a usage event (queued for batch processing)
|
|
56
|
+
*/
|
|
57
|
+
async logUsage(input) {
|
|
58
|
+
// Add to queue for batch processing
|
|
59
|
+
this.eventQueue.push(input);
|
|
60
|
+
// Flush immediately if queue is full
|
|
61
|
+
if (this.eventQueue.length >= this.BATCH_SIZE) {
|
|
62
|
+
await this.flushQueue();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Log a usage event immediately (bypasses queue)
|
|
67
|
+
*/
|
|
68
|
+
async logUsageImmediate(input) {
|
|
69
|
+
try {
|
|
70
|
+
// Enrich with category if missing and type is job
|
|
71
|
+
if (input.type === 'job' && (!input.category || input.category === 'uncategorized')) {
|
|
72
|
+
input.category = await this.resolveCategory(input.name);
|
|
73
|
+
}
|
|
74
|
+
await this.dbService.logUsageEvent(input);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error('Failed to log usage event:', error);
|
|
78
|
+
// Don't throw - analytics failures shouldn't break main functionality
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve category for a job name
|
|
83
|
+
*/
|
|
84
|
+
async resolveCategory(jobName) {
|
|
85
|
+
if (!this.jobMap) {
|
|
86
|
+
await this.loadJobMap();
|
|
87
|
+
}
|
|
88
|
+
return this.jobMap?.get(jobName) || 'uncategorized';
|
|
89
|
+
}
|
|
90
|
+
async resolveCanonicalCategory(name, type, fallbackCategory) {
|
|
91
|
+
if (type === 'job') {
|
|
92
|
+
const resolved = await this.resolveCategory(name);
|
|
93
|
+
if (resolved && resolved !== 'uncategorized') {
|
|
94
|
+
return resolved;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return fallbackCategory || undefined;
|
|
98
|
+
}
|
|
99
|
+
async loadJobMap() {
|
|
100
|
+
try {
|
|
101
|
+
this.jobMap = new Map();
|
|
102
|
+
// Find registry root - using process.cwd() for server-side resolution
|
|
103
|
+
const registryRoot = path.join(process.cwd(), 'registry', 'jobs');
|
|
104
|
+
if (!fs.existsSync(registryRoot)) {
|
|
105
|
+
console.warn(`[UsageAnalyticsService] Could not find registry/jobs directory at ${registryRoot}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.scanRegistryDir(registryRoot);
|
|
109
|
+
console.log(`[UsageAnalyticsService] Loaded ${this.jobMap.size} jobs from registry`);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
console.error('[UsageAnalyticsService] Failed to load job registry:', error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
scanRegistryDir(dir, base = '') {
|
|
116
|
+
if (!this.jobMap)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
120
|
+
for (const item of items) {
|
|
121
|
+
const fullPath = path.join(dir, item.name);
|
|
122
|
+
const relPath = path.relative(path.join(process.cwd(), 'registry', 'jobs'), fullPath);
|
|
123
|
+
const parts = relPath.split(path.sep);
|
|
124
|
+
if (item.isDirectory()) {
|
|
125
|
+
this.scanRegistryDir(fullPath, path.join(base, item.name));
|
|
126
|
+
}
|
|
127
|
+
else if (item.name.endsWith('.md')) {
|
|
128
|
+
const jobName = item.name.replace('.md', '');
|
|
129
|
+
// Group ai-manager jobs into ai-management
|
|
130
|
+
// We check if "ai-manager" is in the path OR the name includes "manager" or "analyze-why"
|
|
131
|
+
const lowerName = jobName.toLowerCase();
|
|
132
|
+
if (parts.includes('ai-manager') || lowerName.includes('manager') || lowerName.includes('coaching') || lowerName.includes('analyze-why')) {
|
|
133
|
+
this.jobMap.set(jobName, 'ai-management');
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
// Find category (one level up from filename)
|
|
137
|
+
const category = parts[parts.length - 2] || 'uncategorized';
|
|
138
|
+
this.jobMap.set(jobName, category);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
console.error(`[UsageAnalyticsService] Error scanning directory ${dir}:`, error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Get usage statistics for a time period
|
|
149
|
+
*/
|
|
150
|
+
async getUsageStats(query) {
|
|
151
|
+
const timeWindow = this.resolveTimeWindow(query.period || '30d', query.startDate, query.endDate);
|
|
152
|
+
return await this.dbService.getUsageStats(timeWindow, query.types, query.userId, query.repoIdentifier);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Get top components by usage
|
|
156
|
+
*/
|
|
157
|
+
async getTopComponents(limit = 10, period = '30d', types, userId, repoIdentifier, startDate, endDate) {
|
|
158
|
+
const timeWindow = this.resolveTimeWindow(period, startDate, endDate);
|
|
159
|
+
const components = await this.dbService.getTopComponents(limit, timeWindow, types, userId, repoIdentifier);
|
|
160
|
+
return await Promise.all(components.map(async (component) => ({
|
|
161
|
+
...component,
|
|
162
|
+
category: await this.resolveCanonicalCategory(component.name, component.type, component.category)
|
|
163
|
+
})));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get usage pattern for a specific user
|
|
167
|
+
*/
|
|
168
|
+
async getUserUsagePattern(userId) {
|
|
169
|
+
return await this.dbService.getUserUsagePattern(userId);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Get trend data for a component
|
|
173
|
+
*/
|
|
174
|
+
async getComponentTrend(componentName, componentType, period = '30d', userId, dateUnit, repoIdentifier, startDate, endDate) {
|
|
175
|
+
const timeWindow = this.resolveTimeWindow(period, startDate, endDate);
|
|
176
|
+
return await this.dbService.getComponentTrend(componentName, componentType, timeWindow, userId, dateUnit, repoIdentifier);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Get job timeline data for bubble chart
|
|
180
|
+
*/
|
|
181
|
+
async getJobRunsOverTime(period = '30d', userId, dateUnit, repoIdentifier, startDate, endDate) {
|
|
182
|
+
const timeWindow = this.resolveTimeWindow(period, startDate, endDate);
|
|
183
|
+
const timeline = await this.dbService.getJobRunsOverTime(timeWindow, userId, dateUnit, repoIdentifier);
|
|
184
|
+
return await Promise.all(timeline.map(async (item) => ({
|
|
185
|
+
...item,
|
|
186
|
+
category: await this.resolveCanonicalCategory(item.name, 'job', item.category)
|
|
187
|
+
})));
|
|
188
|
+
}
|
|
189
|
+
async getDominantJobCategory(period = '30d', userId, repoIdentifier, startDate, endDate) {
|
|
190
|
+
const components = await this.getTopComponents(10000, period, ['job'], userId, repoIdentifier, startDate, endDate);
|
|
191
|
+
if (components.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
const totals = new Map();
|
|
194
|
+
for (const component of components) {
|
|
195
|
+
const category = component.category || 'uncategorized';
|
|
196
|
+
totals.set(category, (totals.get(category) || 0) + component.count);
|
|
197
|
+
}
|
|
198
|
+
let dominant = null;
|
|
199
|
+
for (const [category, count] of totals.entries()) {
|
|
200
|
+
if (!dominant || count > dominant.count) {
|
|
201
|
+
dominant = { category, count };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return dominant;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Export usage data as CSV
|
|
208
|
+
*/
|
|
209
|
+
async exportUsageData(query) {
|
|
210
|
+
const stats = await this.getUsageStats(query);
|
|
211
|
+
const topComponents = await this.getTopComponents(query.limit || 50, query.period || '30d', query.types, query.userId, query.repoIdentifier, query.startDate, query.endDate);
|
|
212
|
+
// Create CSV content
|
|
213
|
+
let csv = 'Component Name,Type,Category,Usage Count,Success Rate,Last Used\n';
|
|
214
|
+
topComponents.forEach(component => {
|
|
215
|
+
csv += `"${component.name}","${component.type}","${component.category || 'N/A'}",${component.count},${(component.successRate || 0).toFixed(2)}%,"${component.lastUsed ? component.lastUsed.toISOString() : 'N/A'}"\n`;
|
|
216
|
+
});
|
|
217
|
+
return csv;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Clean up old usage events
|
|
221
|
+
*/
|
|
222
|
+
async cleanupOldEvents(retentionDays = 90) {
|
|
223
|
+
return await this.dbService.cleanupOldUsageEvents(retentionDays);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Initialize the analytics system
|
|
227
|
+
*/
|
|
228
|
+
async initialize() {
|
|
229
|
+
await this.dbService.initializeUsageEventsCollection();
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Get unique repositories that have generated telemetry for a user
|
|
233
|
+
*/
|
|
234
|
+
async getUniqueRepos(userId) {
|
|
235
|
+
return await this.dbService.getUniqueRepos(userId);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Gracefully shutdown the service
|
|
239
|
+
*/
|
|
240
|
+
async shutdown() {
|
|
241
|
+
if (this.flushInterval) {
|
|
242
|
+
clearInterval(this.flushInterval);
|
|
243
|
+
this.flushInterval = null;
|
|
244
|
+
}
|
|
245
|
+
// Flush any remaining events
|
|
246
|
+
await this.flushQueue();
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Create a usage event for job start (when get_fraim_job is called)
|
|
250
|
+
*/
|
|
251
|
+
async logJobStart(jobId, jobName, userId, sessionId, issueNumber) {
|
|
252
|
+
const jobStartEvent = UsageAnalyticsService.createUsageEvent('job', jobName, userId, sessionId, true, {
|
|
253
|
+
action: 'start',
|
|
254
|
+
issueNumber,
|
|
255
|
+
jobId
|
|
256
|
+
}, await this.resolveCategory(jobName), jobId);
|
|
257
|
+
await this.logUsage(jobStartEvent);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Create a usage event for job completion (when seekMentoring returns nextPhase=null)
|
|
261
|
+
*/
|
|
262
|
+
async logJobComplete(jobId, jobName, userId, sessionId, finalPhase) {
|
|
263
|
+
const jobCompleteEvent = UsageAnalyticsService.createUsageEvent('job', jobName, userId, sessionId, true, {
|
|
264
|
+
action: 'complete',
|
|
265
|
+
finalPhase,
|
|
266
|
+
jobId
|
|
267
|
+
}, await this.resolveCategory(jobName), jobId, finalPhase);
|
|
268
|
+
await this.logUsage(jobCompleteEvent);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Log a quality score for a job that produces quality assessments (e.g., process-interview-notes, triage-customer-needs).
|
|
272
|
+
* Writes to the dedicated fraim_quality_scores collection (no TTL — retained indefinitely).
|
|
273
|
+
*/
|
|
274
|
+
async logQualityScore(userId, jobName, jobId, sessionId, scores, artifactPath, repoIdentifier, reviewContext) {
|
|
275
|
+
try {
|
|
276
|
+
await this.dbService.insertQualityScore({
|
|
277
|
+
userId,
|
|
278
|
+
jobName,
|
|
279
|
+
jobId,
|
|
280
|
+
sessionId,
|
|
281
|
+
scores,
|
|
282
|
+
reviewContext,
|
|
283
|
+
artifactPath,
|
|
284
|
+
repoIdentifier,
|
|
285
|
+
createdAt: new Date()
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
console.error(`[UsageAnalyticsService] Failed to log quality score for ${jobName}:`, error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Create a usage event for mentoring calls with job tracking
|
|
294
|
+
*/
|
|
295
|
+
async logMentoringCall(jobId, jobName, userId, sessionId, currentPhase, status, nextPhase, success = true) {
|
|
296
|
+
const mentoringEvent = UsageAnalyticsService.createUsageEvent('mentoring', jobName, userId, sessionId, success, {
|
|
297
|
+
currentPhase,
|
|
298
|
+
status,
|
|
299
|
+
nextPhase,
|
|
300
|
+
jobId
|
|
301
|
+
}, await this.resolveCategory(jobName), jobId, currentPhase);
|
|
302
|
+
await this.logUsage(mentoringEvent);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Validate that a jobId exists for the user (check recent usage events)
|
|
306
|
+
*/
|
|
307
|
+
async validateJobId(jobId, userId) {
|
|
308
|
+
try {
|
|
309
|
+
// Check if there's a recent job start event for this jobId and user
|
|
310
|
+
const recentEvents = await this.dbService.getDb();
|
|
311
|
+
const collection = recentEvents.collection('fraim_usage_events');
|
|
312
|
+
const jobStartEvent = await collection.findOne({
|
|
313
|
+
jobId,
|
|
314
|
+
userId,
|
|
315
|
+
type: 'job',
|
|
316
|
+
'args.action': 'start',
|
|
317
|
+
createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } // Within last 24 hours
|
|
318
|
+
});
|
|
319
|
+
return jobStartEvent !== null;
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
console.error('Error validating jobId:', error);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Get job completion metrics using usage events
|
|
328
|
+
*/
|
|
329
|
+
async getJobCompletionMetrics(period = '30d', jobName, userId, repoIdentifier, startDate, endDate) {
|
|
330
|
+
const timeWindow = this.resolveTimeWindow(period, startDate, endDate);
|
|
331
|
+
return await this.dbService.getJobCompletionMetrics(timeWindow.startDate, jobName, userId, repoIdentifier, timeWindow.endDate);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Start the batch processor for queued events
|
|
335
|
+
*/
|
|
336
|
+
startBatchProcessor() {
|
|
337
|
+
this.flushInterval = setInterval(async () => {
|
|
338
|
+
if (this.eventQueue.length > 0) {
|
|
339
|
+
await this.flushQueue();
|
|
340
|
+
}
|
|
341
|
+
}, this.FLUSH_INTERVAL_MS);
|
|
342
|
+
this.flushInterval.unref?.();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Flush queued events to database
|
|
346
|
+
*/
|
|
347
|
+
async flushQueue() {
|
|
348
|
+
if (this.eventQueue.length === 0)
|
|
349
|
+
return;
|
|
350
|
+
// Skip flush if DB is not connected (e.g., during server shutdown)
|
|
351
|
+
if (!this.dbService.getClient())
|
|
352
|
+
return;
|
|
353
|
+
const eventsToFlush = this.eventQueue.splice(0, this.BATCH_SIZE);
|
|
354
|
+
try {
|
|
355
|
+
// Process events in parallel for better performance
|
|
356
|
+
await Promise.all(eventsToFlush.map(event => this.dbService.logUsageEvent(event)));
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
// Only re-queue if the error is transient (not a disconnection)
|
|
360
|
+
if (error instanceof Error && error.message.includes('not connected')) {
|
|
361
|
+
return; // DB shut down — drop events
|
|
362
|
+
}
|
|
363
|
+
console.error('Failed to flush usage events:', error);
|
|
364
|
+
// Re-queue failed events (up to a limit to prevent infinite growth)
|
|
365
|
+
if (this.eventQueue.length < this.BATCH_SIZE * 2) {
|
|
366
|
+
this.eventQueue.unshift(...eventsToFlush);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Parse period string to days
|
|
372
|
+
*/
|
|
373
|
+
parsePeriodToDays(period) {
|
|
374
|
+
const match = period.match(/^(\d+)([dwmy])$/);
|
|
375
|
+
if (!match)
|
|
376
|
+
return 30; // default to 30 days
|
|
377
|
+
const [, num, unit] = match;
|
|
378
|
+
const value = parseInt(num, 10);
|
|
379
|
+
switch (unit) {
|
|
380
|
+
case 'd': return value;
|
|
381
|
+
case 'w': return value * 7;
|
|
382
|
+
case 'm': return value * 30;
|
|
383
|
+
case 'y': return value * 365;
|
|
384
|
+
default: return 30;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
resolveTimeWindow(period, startDateInput, endDateInput) {
|
|
388
|
+
const now = new Date();
|
|
389
|
+
if (startDateInput || endDateInput) {
|
|
390
|
+
const startDate = startDateInput ? new Date(startDateInput) : new Date(now);
|
|
391
|
+
const endDate = endDateInput ? new Date(endDateInput) : now;
|
|
392
|
+
const days = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)) + 1);
|
|
393
|
+
return { startDate, endDate, days };
|
|
394
|
+
}
|
|
395
|
+
const days = this.parsePeriodToDays(period);
|
|
396
|
+
return {
|
|
397
|
+
startDate: new Date(now.getTime() - days * 24 * 60 * 60 * 1000),
|
|
398
|
+
endDate: now,
|
|
399
|
+
days
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Create a usage event input from parameters
|
|
404
|
+
*/
|
|
405
|
+
static createUsageEvent(type, name, userId, sessionId, success = true, args, category, jobId, jobPhase) {
|
|
406
|
+
return {
|
|
407
|
+
type,
|
|
408
|
+
name,
|
|
409
|
+
userId,
|
|
410
|
+
sessionId,
|
|
411
|
+
success,
|
|
412
|
+
args,
|
|
413
|
+
category,
|
|
414
|
+
jobId,
|
|
415
|
+
jobPhase
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
exports.UsageAnalyticsService = UsageAnalyticsService;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeWorkspaceEmail = normalizeWorkspaceEmail;
|
|
4
|
+
exports.resolveWorkspaceId = resolveWorkspaceId;
|
|
5
|
+
function normalizeWorkspaceEmail(email) {
|
|
6
|
+
if (!email)
|
|
7
|
+
return null;
|
|
8
|
+
const trimmed = email.trim().toLowerCase();
|
|
9
|
+
return trimmed || null;
|
|
10
|
+
}
|
|
11
|
+
function resolveWorkspaceId(input) {
|
|
12
|
+
if (input.workspaceId)
|
|
13
|
+
return input.workspaceId;
|
|
14
|
+
if (input.stripeCustomerId)
|
|
15
|
+
return `cust:${input.stripeCustomerId}`;
|
|
16
|
+
const normalizedEmail = normalizeWorkspaceEmail(input.email);
|
|
17
|
+
if (normalizedEmail) {
|
|
18
|
+
return `user:${normalizedEmail}`;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|