activityManager.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import { getActiveTimeCounter as getActiveTimeCounterImpl } from '../bootstrap/state.js'
  2. type ActivityManagerOptions = {
  3. getNow?: () => number
  4. getActiveTimeCounter?: typeof getActiveTimeCounterImpl
  5. }
  6. /**
  7. * ActivityManager handles generic activity tracking for both user and CLI operations.
  8. * It automatically deduplicates overlapping activities and provides separate metrics
  9. * for user vs CLI active time.
  10. */
  11. export class ActivityManager {
  12. private activeOperations = new Set<string>()
  13. private lastUserActivityTime: number = 0 // Start with 0 to indicate no activity yet
  14. private lastCLIRecordedTime: number
  15. private isCLIActive: boolean = false
  16. private readonly USER_ACTIVITY_TIMEOUT_MS = 5000 // 5 seconds
  17. private readonly getNow: () => number
  18. private readonly getActiveTimeCounter: typeof getActiveTimeCounterImpl
  19. private static instance: ActivityManager | null = null
  20. constructor(options?: ActivityManagerOptions) {
  21. this.getNow = options?.getNow ?? (() => Date.now())
  22. this.getActiveTimeCounter =
  23. options?.getActiveTimeCounter ?? getActiveTimeCounterImpl
  24. this.lastCLIRecordedTime = this.getNow()
  25. }
  26. static getInstance(): ActivityManager {
  27. if (!ActivityManager.instance) {
  28. ActivityManager.instance = new ActivityManager()
  29. }
  30. return ActivityManager.instance
  31. }
  32. /**
  33. * Reset the singleton instance (for testing purposes)
  34. */
  35. static resetInstance(): void {
  36. ActivityManager.instance = null
  37. }
  38. /**
  39. * Create a new instance with custom options (for testing purposes)
  40. */
  41. static createInstance(options?: ActivityManagerOptions): ActivityManager {
  42. ActivityManager.instance = new ActivityManager(options)
  43. return ActivityManager.instance
  44. }
  45. /**
  46. * Called when user interacts with the CLI (typing, commands, etc.)
  47. */
  48. recordUserActivity(): void {
  49. // Don't record user time if CLI is active (CLI takes precedence)
  50. if (!this.isCLIActive && this.lastUserActivityTime !== 0) {
  51. const now = this.getNow()
  52. const timeSinceLastActivity = (now - this.lastUserActivityTime) / 1000
  53. if (timeSinceLastActivity > 0) {
  54. const activeTimeCounter = this.getActiveTimeCounter()
  55. if (activeTimeCounter) {
  56. const timeoutSeconds = this.USER_ACTIVITY_TIMEOUT_MS / 1000
  57. // Only record time if within the timeout window
  58. if (timeSinceLastActivity < timeoutSeconds) {
  59. activeTimeCounter.add(timeSinceLastActivity, { type: 'user' })
  60. }
  61. }
  62. }
  63. }
  64. // Update the last user activity timestamp
  65. this.lastUserActivityTime = this.getNow()
  66. }
  67. /**
  68. * Starts tracking CLI activity (tool execution, AI response, etc.)
  69. */
  70. startCLIActivity(operationId: string): void {
  71. // If operation already exists, it likely means the previous one didn't clean up
  72. // properly (e.g., component crashed/unmounted without calling end). Force cleanup
  73. // to avoid overestimating time - better to underestimate than overestimate.
  74. if (this.activeOperations.has(operationId)) {
  75. this.endCLIActivity(operationId)
  76. }
  77. const wasEmpty = this.activeOperations.size === 0
  78. this.activeOperations.add(operationId)
  79. if (wasEmpty) {
  80. this.isCLIActive = true
  81. this.lastCLIRecordedTime = this.getNow()
  82. }
  83. }
  84. /**
  85. * Stops tracking CLI activity
  86. */
  87. endCLIActivity(operationId: string): void {
  88. this.activeOperations.delete(operationId)
  89. if (this.activeOperations.size === 0) {
  90. // Last operation ended - CLI becoming inactive
  91. // Record the CLI time before switching to inactive
  92. const now = this.getNow()
  93. const timeSinceLastRecord = (now - this.lastCLIRecordedTime) / 1000
  94. if (timeSinceLastRecord > 0) {
  95. const activeTimeCounter = this.getActiveTimeCounter()
  96. if (activeTimeCounter) {
  97. activeTimeCounter.add(timeSinceLastRecord, { type: 'cli' })
  98. }
  99. }
  100. this.lastCLIRecordedTime = now
  101. this.isCLIActive = false
  102. }
  103. }
  104. /**
  105. * Convenience method to track an async operation automatically (mainly for testing/debugging)
  106. */
  107. async trackOperation<T>(
  108. operationId: string,
  109. fn: () => Promise<T>,
  110. ): Promise<T> {
  111. this.startCLIActivity(operationId)
  112. try {
  113. return await fn()
  114. } finally {
  115. this.endCLIActivity(operationId)
  116. }
  117. }
  118. /**
  119. * Gets current activity states (mainly for testing/debugging)
  120. */
  121. getActivityStates(): {
  122. isUserActive: boolean
  123. isCLIActive: boolean
  124. activeOperationCount: number
  125. } {
  126. const now = this.getNow()
  127. const timeSinceUserActivity = (now - this.lastUserActivityTime) / 1000
  128. const isUserActive =
  129. timeSinceUserActivity < this.USER_ACTIVITY_TIMEOUT_MS / 1000
  130. return {
  131. isUserActive,
  132. isCLIActive: this.isCLIActive,
  133. activeOperationCount: this.activeOperations.size,
  134. }
  135. }
  136. }
  137. // Export singleton instance
  138. export const activityManager = ActivityManager.getInstance()