/** * SyncConflictDiagnosticsAgent — A2A pipeline for ChronoMind sync conflict diagnostics. * * Agent roster (4 steps): * 1. ConflictDetectorAgent — query telemetry for sync_conflict events for a user * 2. SyncStateInspectorAgent — pull current sync status (queue depth, unsynced count) * 3. DiagnosticsSessionAgent — create platform diagnostics session when conflicts found * 4. ConflictReportAgent — assemble report with root cause analysis + remediation * * MCP tools: * chronomind.sync.diagnoseConflicts(userId, deviceId?, from?, to?) — run pipeline */ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { registerTool } from '../tools/registry.js'; import type { McpToolRequest } from '../tools/types.js'; import { telemetryQuery, diagnosticsCreateSession, diagnosticsGetLogs, diagnosticsUpdateSession, type DebugSession, } from '../../lib/platform-client.js'; import { chronomindSyncStatus } from '../../lib/chronomind-client.js'; // ── Types ────────────────────────────────────────────────────────────────────── type ConflictPattern = 'version_clash' | 'concurrent_edit' | 'stale_device' | 'unknown'; interface ConflictDetection { userId: string; deviceId: string | null; conflictCount: number; recentConflicts: unknown[]; conflictPattern: ConflictPattern; fromTime: string; toTime: string; } interface SyncStateResult { unsyncedCount: number; pendingCount: number; lastSyncAt: string | null; activeTimers: number; } interface DiagnosticsCapture { skipped: boolean; skipReason?: string; session?: DebugSession; logEntries: unknown[]; } export interface SyncConflictReport { runId: string; productId: 'chronomind'; userId: string; deviceId: string | null; conflictCount: number; conflictPattern: ConflictPattern; unsyncedCount: number; pendingCount: number; diagnosticsSessionId: string | null; logEntries: unknown[]; rootCauseSummary: string; recommendedAction: string; generatedAt: string; } // ── Step 1: ConflictDetectorAgent ───────────────────────────────────────────── async function detectConflicts( userId: string, deviceId: string | null, fromTime: string, toTime: string, opts: { token?: string; requestId?: string } ): Promise { let conflictCount = 0; let recentConflicts: unknown[] = []; try { const result = await telemetryQuery( { productId: 'chronomind', eventType: 'sync_conflict', from: fromTime, to: toTime, limit: 20, }, { ...opts, productId: 'chronomind' } ); conflictCount = result.total; recentConflicts = result.events; } catch { // best-effort } let conflictPattern: ConflictPattern = 'unknown'; if (conflictCount > 0) { const events = recentConflicts as Array>; const hasVersionClash = events.some(e => String(e['errorCode'] ?? '').includes('version')); const hasConcurrentEdit = events.some(e => String(e['errorCode'] ?? '').includes('concurrent')); const hasStaleDevice = events.some(e => String(e['errorCode'] ?? '').includes('stale')); if (hasVersionClash) conflictPattern = 'version_clash'; else if (hasConcurrentEdit) conflictPattern = 'concurrent_edit'; else if (hasStaleDevice) conflictPattern = 'stale_device'; } return { userId, deviceId, conflictCount, recentConflicts, conflictPattern, fromTime, toTime, }; } // ── Step 2: SyncStateInspectorAgent ─────────────────────────────────────────── async function inspectSyncState(opts: { token?: string; requestId?: string; }): Promise { try { const status = await chronomindSyncStatus(opts); return { unsyncedCount: status.unsyncedCount ?? 0, pendingCount: status.pending ?? 0, lastSyncAt: status.lastSyncedAt ?? null, activeTimers: status.active ?? 0, }; } catch { return { unsyncedCount: 0, pendingCount: 0, lastSyncAt: null, activeTimers: 0 }; } } // ── Step 3: DiagnosticsSessionAgent ─────────────────────────────────────────── async function captureDiagnostics( runId: string, userId: string, conflictDetection: ConflictDetection, opts: { token?: string; requestId?: string } ): Promise { if (conflictDetection.conflictCount === 0) { return { skipped: true, skipReason: 'no_conflicts_detected', logEntries: [] }; } try { const session = await diagnosticsCreateSession( { productId: 'chronomind', targetUserId: userId, maxDurationMinutes: 30, }, opts ); const logs = await diagnosticsGetLogs(session.id, { limit: 50 }, opts).catch(() => ({ logs: [], })); await diagnosticsUpdateSession(session.id, { status: 'completed' }, opts).catch(() => null); return { skipped: false, session, logEntries: (logs as { logs: unknown[] }).logs }; } catch { return { skipped: true, skipReason: 'diagnostics_session_failed', logEntries: [] }; } } // ── Step 4: ConflictReportAgent ─────────────────────────────────────────────── function buildConflictReport( runId: string, detection: ConflictDetection, syncState: SyncStateResult, diagnostics: DiagnosticsCapture ): SyncConflictReport { const now = new Date().toISOString(); const rootCauseSummary = (() => { if (detection.conflictCount === 0) return 'No sync conflicts detected in the specified time window.'; switch (detection.conflictPattern) { case 'version_clash': return `${detection.conflictCount} version clash conflict(s) detected. Timer sync versions diverged — likely caused by the same timer being edited on two devices while offline.`; case 'concurrent_edit': return `${detection.conflictCount} concurrent edit conflict(s) detected. Multiple devices modified the same timer simultaneously.`; case 'stale_device': return `${detection.conflictCount} stale device conflict(s) detected. A device is syncing with an outdated timer version — device likely offline for an extended period.`; default: return `${detection.conflictCount} sync conflict(s) detected with unclassified pattern. Check diagnostics logs for detailed error codes.`; } })(); const recommendedAction = (() => { if (detection.conflictCount === 0) return 'No action required.'; if (syncState.unsyncedCount > 10) return 'Force full re-sync from the primary device. Clear the offline queue on secondary devices before next sync.'; if (detection.conflictPattern === 'version_clash') return 'Trigger a full timer re-sync (POST /timers/sync with force=true) from the device with the latest edits.'; if (detection.conflictPattern === 'stale_device') return "Clear the stale device's local cache and trigger a fresh pull from the server."; return 'Review diagnostics logs for detailed conflict context, then force re-sync.'; })(); return { runId, productId: 'chronomind', userId: detection.userId, deviceId: detection.deviceId, conflictCount: detection.conflictCount, conflictPattern: detection.conflictPattern, unsyncedCount: syncState.unsyncedCount, pendingCount: syncState.pendingCount, diagnosticsSessionId: diagnostics.session?.id ?? null, logEntries: diagnostics.logEntries, rootCauseSummary, recommendedAction, generatedAt: now, }; } // ── Pipeline runner ──────────────────────────────────────────────────────────── async function runSyncConflictPipeline( userId: string, deviceId: string | null, fromTime: string, toTime: string, req: McpToolRequest ): Promise { const runId = randomUUID(); const opts = { token: req.headers.authorization?.slice(7), requestId: req.id, }; req.log.info({ runId, stepId: 'detect', userId, deviceId }, 'ConflictDetectorAgent start'); const detection = await detectConflicts(userId, deviceId, fromTime, toTime, opts); req.log.info( { runId, stepId: 'detect', conflictCount: detection.conflictCount, pattern: detection.conflictPattern, }, 'ConflictDetectorAgent done' ); req.log.info({ runId, stepId: 'syncState' }, 'SyncStateInspectorAgent start'); const syncState = await inspectSyncState(opts); req.log.info( { runId, stepId: 'syncState', unsyncedCount: syncState.unsyncedCount }, 'SyncStateInspectorAgent done' ); req.log.info( { runId, stepId: 'diagnostics', conflictCount: detection.conflictCount }, 'DiagnosticsSessionAgent start' ); const diagnostics = await captureDiagnostics(runId, userId, detection, opts); req.log.info( { runId, stepId: 'diagnostics', skipped: diagnostics.skipped, sessionId: diagnostics.session?.id, }, 'DiagnosticsSessionAgent done' ); req.log.info({ runId, stepId: 'report' }, 'ConflictReportAgent start'); const report = buildConflictReport(runId, detection, syncState, diagnostics); req.log.info( { runId, stepId: 'report', conflictCount: report.conflictCount }, 'ConflictReportAgent done' ); return report; } // ── MCP tool registration ───────────────────────────────────────────────────── registerTool({ name: 'chronomind.sync.diagnoseConflicts', description: 'A2A pipeline: queries ChronoMind telemetry for sync_conflict events, inspects current sync queue state, creates a diagnostics session for affected users, and returns a root-cause conflict report with remediation steps. Requires admin role.', requiredRole: 'admin', inputSchema: z.object({ userId: z.string().min(1).describe('User to run conflict diagnostics for'), deviceId: z.string().optional().describe('Optional: narrow to a specific device ID'), from: z.string().datetime().optional().describe('Start of conflict window (default: 24h ago)'), to: z.string().datetime().optional().describe('End of conflict window (default: now)'), }), async execute(args, req) { const now = new Date(); const toTime = args.to ?? now.toISOString(); const fromTime = args.from ?? new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); return runSyncConflictPipeline(args.userId, args.deviceId ?? null, fromTime, toTime, req); }, });