fraim 2.0.340 → 2.0.341

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.
@@ -745,7 +745,11 @@ router.post('/events', async (req, res) => {
745
745
  if (!analyticsService) {
746
746
  throw new Error('Analytics service not initialized');
747
747
  }
748
- await Promise.all(processedEvents.map(event => analyticsService.logUsageImmediate(event)));
748
+ // Wait for every write before cleanup, even if another write fails.
749
+ const results = await Promise.allSettled(processedEvents.map(event => analyticsService.logUsageImmediate(event, { throwOnError: true })));
750
+ const failure = results.find(result => result.status === 'rejected');
751
+ if (failure?.status === 'rejected')
752
+ throw failure.reason;
749
753
  res.json({
750
754
  message: 'Events processed',
751
755
  count: processedEvents.length
@@ -756,8 +760,11 @@ router.post('/events', async (req, res) => {
756
760
  res.status(500).json({ error: 'Internal server error' });
757
761
  }
758
762
  finally {
759
- if (dbService) {
760
- await dbService.close();
763
+ try {
764
+ await analyticsService?.shutdown();
765
+ }
766
+ finally {
767
+ await dbService?.close();
761
768
  }
762
769
  }
763
770
  });
@@ -46,6 +46,7 @@ const compat_1 = require("../config/compat");
46
46
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
47
47
  const quality_evidence_1 = require("../core/quality-evidence");
48
48
  const handoff_contracts_1 = require("../core/handoff-contracts");
49
+ const test_evidence_contract_1 = require("../core/test-evidence-contract");
49
50
  const feature_flags_1 = require("../config/feature-flags");
50
51
  const persona_entitlement_service_1 = require("./persona-entitlement-service");
51
52
  exports.DEFAULT_LAUNCH_PHRASE_MAPPINGS = {
@@ -69,6 +70,7 @@ function buildTrainingModeBlock(mode) {
69
70
  }
70
71
  class McpService {
71
72
  constructor(registryService, sessionManager, aiMentor, dbService, analyticsServiceOrVersion, serverVersion) {
73
+ this.phaseStartMarkers = new Map();
72
74
  this.registryService = registryService;
73
75
  this.sessionManager = sessionManager;
74
76
  this.aiMentor = aiMentor;
@@ -226,8 +228,8 @@ class McpService {
226
228
  throw new Error('Argument "job" is required for get_fraim_job');
227
229
  return await this.handleGetJob(args.job, context.apiKey, context.userId);
228
230
  case 'seekMentoring':
229
- if (!args.jobName || !args.issueNumber || !args.currentPhase || !args.status || !args.jobId) {
230
- throw new Error('Arguments "jobName", "jobId", "issueNumber", "currentPhase", and "status" are required for seekMentoring');
231
+ if (!args.jobName || !args.issueNumber || !args.currentPhase || !args.status) {
232
+ throw new Error('Arguments "jobName", "issueNumber", "currentPhase", and "status" are required for seekMentoring');
231
233
  }
232
234
  return await this.handleSeekMentoring(args, context.userId);
233
235
  case 'list_fraim_jobs':
@@ -692,10 +694,15 @@ class McpService {
692
694
  if (personaLock) {
693
695
  return personaLock;
694
696
  }
695
- // Validate jobId exists and belongs to the user
696
- if (userId && this.analyticsService) {
697
+ // Missing IDs still allow mentoring and session-scoped start matching.
698
+ // Unknown IDs must not produce analytics records for another run.
699
+ let trackingWarning = '';
700
+ let canTrackJob = Boolean(args.jobId && userId && this.analyticsService);
701
+ if (canTrackJob) {
697
702
  const isValidJob = await this.analyticsService.validateJobId(args.jobId, userId);
698
703
  if (!isValidJob) {
704
+ canTrackJob = false;
705
+ trackingWarning = `Job tracking notice: jobId \`${args.jobId}\` is unrecognized for this user; continuing without job analytics.\n\n`;
699
706
  console.warn(`[seekMentoring] Unrecognized jobId: ${args.jobId} for user ${userId} — proceeding without tracking`);
700
707
  }
701
708
  }
@@ -744,10 +751,21 @@ class McpService {
744
751
  : 'enforce';
745
752
  if (reviewHandoffApplies && enforcementMode === 'enforce') {
746
753
  return {
747
- content: [{ type: 'text', text: (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, missingField, handoffErrors) }]
754
+ content: [{ type: 'text', text: trackingWarning + (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, missingField, handoffErrors) }]
748
755
  };
749
756
  }
750
757
  }
758
+ const testEvidenceErrors = (0, test_evidence_contract_1.validateTestEvidenceContract)({
759
+ currentPhase: args.currentPhase,
760
+ status: args.status,
761
+ findings: args.findings,
762
+ phases: handoffPhaseMap,
763
+ });
764
+ if (testEvidenceErrors.length > 0) {
765
+ return {
766
+ content: [{ type: 'text', text: trackingWarning + (0, test_evidence_contract_1.buildTestEvidenceRejectionMessage)(args.currentPhase, testEvidenceErrors) }]
767
+ };
768
+ }
751
769
  // Quality enforcement (Issue #251): quality-producing jobs MUST emit
752
770
  // a valid `evidence.quality` object on their final completion call.
753
771
  //
@@ -771,7 +789,7 @@ class McpService {
771
789
  ];
772
790
  if (qualityErrors.length > 0) {
773
791
  const rejectionMessage = this.buildQualityRejectionMessage(args.jobName, args.currentPhase, qualityErrors);
774
- if (userId && this.analyticsService) {
792
+ if (canTrackJob && userId && this.analyticsService) {
775
793
  try {
776
794
  const sessionId = args.sessionId || 'unknown';
777
795
  await this.analyticsService.logMentoringCall(args.jobId, args.jobName, userId, sessionId, args.currentPhase, 'incomplete', args.currentPhase, false);
@@ -781,17 +799,58 @@ class McpService {
781
799
  }
782
800
  }
783
801
  return {
784
- content: [{ type: 'text', text: rejectionMessage }]
802
+ content: [{ type: 'text', text: trackingWarning + rejectionMessage }]
785
803
  };
786
804
  }
787
805
  }
806
+ // Only accepted calls update the marker. A rejected completion must
807
+ // leave its start available for the corrected completion request.
808
+ let phaseStartWarning = '';
809
+ const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
810
+ const isPhaseCompletion = args.status === 'complete' || args.status === 'failure';
811
+ if (sessionId && sessionId !== 'unknown') {
812
+ const markerKey = JSON.stringify([userId ?? null, sessionId, args.jobName]);
813
+ const context = {
814
+ sessionId,
815
+ jobName: args.jobName,
816
+ phaseId: args.currentPhase,
817
+ ...(args.jobId ? { jobId: args.jobId } : {}),
818
+ };
819
+ const marker = this.phaseStartMarkers.get(markerKey);
820
+ const timestamp = new Date().toISOString();
821
+ if (args.status === 'starting') {
822
+ this.phaseStartMarkers.set(markerKey, (0, handoff_contracts_1.recordPhaseStart)(marker, { ...context, timestamp }));
823
+ }
824
+ else if (isPhaseCompletion) {
825
+ const inspection = (0, handoff_contracts_1.inspectPhaseCompletion)(marker, context);
826
+ if (inspection.missingStart)
827
+ phaseStartWarning = (0, handoff_contracts_1.buildPhaseStartWarning)(args.currentPhase);
828
+ const completed = (0, handoff_contracts_1.commitPhaseCompletion)(marker, inspection, { ...context, timestamp });
829
+ this.phaseStartMarkers.set(markerKey, completed);
830
+ if (args.status === 'complete' && result.nextPhase === null) {
831
+ // Concurrent runs can share the session and job name.
832
+ // Clear only this run (including starts before its ID was known).
833
+ const completedJobId = args.jobId || completed.phaseInstances?.find((instance) => instance.instanceId === inspection.instanceId)?.jobId;
834
+ const remaining = completed.phaseInstances?.filter((instance) => instance.jobId && instance.jobId !== completedJobId) || [];
835
+ if (remaining.length > 0) {
836
+ this.phaseStartMarkers.set(markerKey, { phaseInstances: remaining });
837
+ }
838
+ else {
839
+ this.phaseStartMarkers.delete(markerKey);
840
+ }
841
+ }
842
+ }
843
+ }
844
+ else if (isPhaseCompletion) {
845
+ phaseStartWarning = `Phase-start notice: phase \`${args.currentPhase}\` has no identifiable session, so its start cannot be matched to this run. The missing match was flagged, but this completion remains accepted.\n\n`;
846
+ }
788
847
  // Update job execution tracking using usage events
789
- if (userId && this.analyticsService) {
848
+ if (canTrackJob && userId && this.analyticsService) {
790
849
  try {
791
850
  const sessionId = args.sessionId || 'unknown';
792
851
  await this.analyticsService.logMentoringCall(args.jobId, args.jobName, userId, sessionId, args.currentPhase, args.status, result.nextPhase, result.message ? true : false);
793
852
  // Log explicit completion event when job finishes
794
- if (result.nextPhase === null || result.nextPhase === undefined) {
853
+ if (args.status === 'complete' && result.nextPhase === null) {
795
854
  await this.analyticsService.logJobComplete(args.jobId, args.jobName, userId, sessionId, args.currentPhase);
796
855
  // Emit quality score for jobs that produce quality assessments (Issue #251).
797
856
  // Validation already happened above for quality-producing jobs, so this
@@ -818,8 +877,9 @@ class McpService {
818
877
  return {
819
878
  content: [{
820
879
  type: 'text',
821
- text: result.message
822
- }]
880
+ text: trackingWarning + phaseStartWarning + result.message
881
+ }],
882
+ structuredContent: { phaseAccepted: true, nextPhase: result.nextPhase, phaseStartWarning }
823
883
  };
824
884
  }
825
885
  getJobDescription(jobName, fullPath) {
@@ -63,9 +63,10 @@ class UsageAnalyticsService {
63
63
  }
64
64
  }
65
65
  /**
66
- * Log a usage event immediately (bypasses queue)
66
+ * Log a usage event immediately (bypasses queue).
67
+ * Ingestion callers can require persistence; other callers remain best effort.
67
68
  */
68
- async logUsageImmediate(input) {
69
+ async logUsageImmediate(input, options = {}) {
69
70
  try {
70
71
  // Enrich with category if missing and type is job
71
72
  if (input.type === 'job' && (!input.category || input.category === 'uncategorized')) {
@@ -75,7 +76,9 @@ class UsageAnalyticsService {
75
76
  }
76
77
  catch (error) {
77
78
  console.error('Failed to log usage event:', error);
78
- // Don't throw - analytics failures shouldn't break main functionality
79
+ if (options.throwOnError)
80
+ throw error;
81
+ // Analytics failures should not break unrelated main functionality.
79
82
  }
80
83
  }
81
84
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.340",
3
+ "version": "2.0.341",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {