@xenon-device-management/xenon 2.9.2 → 2.10.0

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.
Files changed (143) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-B87n4pUu.js → AnnotationOverlay-LzfXiiZJ.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-CNZJH6Cs.js → ApiKeyGate-BzFDp4mK.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-DQ-R87ZV.js → BugReportButton-BpgWvmZo.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-dX3e7O-w.js → DeviceMosaicView-CCB-egSH.js} +2 -2
  6. package/lib/public/assets/{EmptyState-D3fG2GHY.js → EmptyState-DkUVmANo.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CUmbJZxI.js → FieldGroup-mbfnfTql.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-Dm2c-UCk.js → FilterMenu-BH5usz4Y.js} +1 -1
  9. package/lib/public/assets/{Menu-Dl4uXMkH.js → Menu-Cbo9mdTk.js} +1 -1
  10. package/lib/public/assets/{Modal-DNmn2RDs.js → Modal-D6XfQHPr.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-Dn3By34o.js → RecordingPage-CmFPkWqH.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-lCUIM4pm.js → RecordingsPage-CSlKk2qC.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-BdA_mHiZ.js → SegmentedControl-BFW9Dlqd.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Dx3WhK7w.js → SettingCard-C4DioTTi.js} +1 -1
  15. package/lib/public/assets/{Table-D_2lhvRM.js → Table-DrKjlL00.js} +1 -1
  16. package/lib/public/assets/{activity-cNHHOKx5.js → activity-C9bE7Jm8.js} +1 -1
  17. package/lib/public/assets/{ai-settings-t6RMrDkg.js → ai-settings-DRYic4oO.js} +1 -1
  18. package/lib/public/assets/{api-keys-CK_qyQvW.js → api-keys-CaQU0g7c.js} +1 -1
  19. package/lib/public/assets/{apps-DCiI3UOb.js → apps-c1gUFmmK.js} +1 -1
  20. package/lib/public/assets/{arrow-left-CnYs8HVw.js → arrow-left-DMvAWA8r.js} +1 -1
  21. package/lib/public/assets/{arrow-right-Cc9qm6UG.js → arrow-right-BEQgxmKT.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-e7yBOb-c.js → arrow-up-right-Ba0e2GeU.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DKrtcIjG.js → auth-shell-COODQnc7.js} +2 -2
  24. package/lib/public/assets/{builds-page-CRd5oEz6.js → builds-page-DZQgWBUs.js} +4 -4
  25. package/lib/public/assets/{button-C0caCjJB.js → button-WW9j43wR.js} +1 -1
  26. package/lib/public/assets/{calendar-BqOOkln_.js → calendar-COUs9VxF.js} +1 -1
  27. package/lib/public/assets/{check-DYSXRG79.js → check-D8ifHIJR.js} +1 -1
  28. package/lib/public/assets/{chevron-right-hCNu0CA-.js → chevron-right-BjKVUlB_.js} +1 -1
  29. package/lib/public/assets/{circle-check-DnQ263fa.js → circle-check-CfP79QE2.js} +1 -1
  30. package/lib/public/assets/{circle-x-BaRKZyz9.js → circle-x-BZbFCixZ.js} +1 -1
  31. package/lib/public/assets/{clock-XP3iBAhg.js → clock-BuTm35Qg.js} +1 -1
  32. package/lib/public/assets/{copy-D8d9UzWE.js → copy-W00zUBwN.js} +1 -1
  33. package/lib/public/assets/{device-explorer-pdM3L9TC.js → device-explorer-BEpS-Y-_.js} +5 -5
  34. package/lib/public/assets/{download-SaN9-umX.js → download-CGz-MS3y.js} +1 -1
  35. package/lib/public/assets/{forgot-password-DfUhEZW5.js → forgot-password-DG7SqVgU.js} +1 -1
  36. package/lib/public/assets/{index-C8pvxG1f.css → index-DQa193-A.css} +1 -1
  37. package/lib/public/assets/{index-IefGCFQQ.js → index-DWHTp1YP.js} +1 -1
  38. package/lib/public/assets/{index-CzaIyL9t.js → index-EkaQM1DK.js} +2 -2
  39. package/lib/public/assets/{input-BqFzAQT3.js → input-ChaTXeCq.js} +1 -1
  40. package/lib/public/assets/{keycodes-DDV2OlpQ.js → keycodes-BhbgeW_r.js} +10 -5
  41. package/lib/public/assets/line-chart-9P3RxScy.js +1 -0
  42. package/lib/public/assets/list-checks-_23IIEEL.js +11 -0
  43. package/lib/public/assets/{lock-Eye1CG8p.js → lock-B2JaqqRI.js} +1 -1
  44. package/lib/public/assets/{login-Co1IXuen.js → login-BEWIfLfC.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-D1fw6iqm.js → maintenance-settings-BGJIJQ-k.js} +1 -1
  46. package/lib/public/assets/{monitor-U5Y-PxW5.js → monitor-BrwMY4G0.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CDAwphUm.js → mouse-pointer-2-DsWDMyrY.js} +1 -1
  48. package/lib/public/assets/{network-BP_U4iDZ.js → network-DfIhNVJv.js} +1 -1
  49. package/lib/public/assets/overview-B1mePY2_.js +6 -0
  50. package/lib/public/assets/{page-header-ZdzNEJp9.js → page-header-DoD3TOzZ.js} +1 -1
  51. package/lib/public/assets/{plus-1k0sxXyU.js → plus-BGvWvpBB.js} +1 -1
  52. package/lib/public/assets/{profile-page-B_GWKRy3.js → profile-page-CvV5aWvl.js} +1 -1
  53. package/lib/public/assets/{reset-password-DizCTkdw.js → reset-password-CGVd31LR.js} +1 -1
  54. package/lib/public/assets/{runbook-page-DWzRiOLZ.js → runbook-page-Bq2cvwb5.js} +1 -1
  55. package/lib/public/assets/{select-CiRW6v17.js → select-CTcD4jWR.js} +1 -1
  56. package/lib/public/assets/selector-detail-redirect-19odlD4B.js +1 -0
  57. package/lib/public/assets/selector-health-page-LqIsLrjW.js +11 -0
  58. package/lib/public/assets/session-detail-page-DQzb8_fq.js +4 -0
  59. package/lib/public/assets/{settings-DhwZoWv7.js → settings-C-EnGIqs.js} +1 -1
  60. package/lib/public/assets/stat-tile-BY0RDTRm.js +1 -0
  61. package/lib/public/assets/{tablet-CPJV3dBq.js → tablet-B4cFM-5B.js} +1 -1
  62. package/lib/public/assets/{teams-D0IBa2Ov.js → teams-BFe-RpYd.js} +1 -1
  63. package/lib/public/assets/time-D-gjmE1o.js +1 -0
  64. package/lib/public/assets/{trash-2-u8MlXUBD.js → trash-2-CJCKBOP9.js} +1 -1
  65. package/lib/public/assets/{upload-Cq8L3kpw.js → upload-DidWkbld.js} +1 -1
  66. package/lib/public/assets/use-builds-data-fnfxatOd.js +8 -0
  67. package/lib/public/assets/{use-password-reset-mode-Dbr2HEfw.js → use-password-reset-mode-iPen3tmC.js} +1 -1
  68. package/lib/public/assets/{users-CZqCE511.js → users-CrcjmRdf.js} +1 -1
  69. package/lib/public/assets/{users-DxLHYmFm.js → users-DBUv0CJx.js} +1 -1
  70. package/lib/public/assets/{video-off-mh1EEzP9.js → video-off-eh69-FKB.js} +1 -1
  71. package/lib/public/assets/view-state-CP9dE1UI.js +1 -0
  72. package/lib/public/assets/{webhook-settings-BYuOib-Q.js → webhook-settings-CQoSshBn.js} +1 -1
  73. package/lib/public/assets/{zap-BOpUur0M.js → zap-CICtWiLn.js} +1 -1
  74. package/lib/public/index.html +2 -2
  75. package/lib/schema.json +5 -0
  76. package/lib/src/app/routers/dashboard.js +86 -40
  77. package/lib/src/app/routers/selector-health.js +75 -0
  78. package/lib/src/app/swagger-docs.js +77 -8
  79. package/lib/src/dashboard/event-manager.js +15 -99
  80. package/lib/src/generated/client/edge.js +27 -3
  81. package/lib/src/generated/client/index-browser.js +24 -0
  82. package/lib/src/generated/client/index.d.ts +8207 -5283
  83. package/lib/src/generated/client/index.js +27 -3
  84. package/lib/src/generated/client/package.json +1 -1
  85. package/lib/src/generated/client/schema.prisma +43 -9
  86. package/lib/src/generated/client/wasm.js +24 -0
  87. package/lib/src/prisma.js +2 -0
  88. package/lib/src/services/CleanupService.js +1 -0
  89. package/lib/src/services/NotificationService.js +3 -4
  90. package/lib/src/services/SelectorStateService.js +151 -113
  91. package/lib/src/services/SelectorVerificationJob.js +14 -9
  92. package/lib/src/services/metrics/AndroidMetricsSampler.js +94 -0
  93. package/lib/src/services/metrics/IOSMetricsSampler.js +120 -0
  94. package/lib/src/services/metrics/SessionMetricsService.js +238 -0
  95. package/lib/src/services/metrics/androidMetrics.js +94 -0
  96. package/lib/src/services/metrics/iosMetrics.js +26 -0
  97. package/lib/src/services/metrics/metricsBody.js +37 -0
  98. package/lib/src/services/metrics/types.js +19 -0
  99. package/lib/src/services/selector-health/access.js +66 -0
  100. package/lib/src/services/selector-health/healingTrend.js +49 -0
  101. package/lib/src/services/selector-health/people.js +27 -0
  102. package/lib/src/services/selector-health/selectorDetail.js +198 -0
  103. package/lib/src/services/selector-health/selectorKeys.js +36 -0
  104. package/lib/src/services/selector-health/selectorList.js +333 -0
  105. package/lib/src/services/selector-health/stateView.js +67 -0
  106. package/lib/test/helpers/scratch-database.js +2 -0
  107. package/lib/test/helpers/selector-health-fixture.js +216 -0
  108. package/lib/test/integration/team-visibility-sessions.spec.js +1 -0
  109. package/lib/test/unit/CleanupService.spec.js +28 -2
  110. package/lib/test/unit/dashboard-events-metrics.spec.js +146 -0
  111. package/lib/test/unit/healing-no-cost.spec.js +134 -0
  112. package/lib/test/unit/healing-state-endpoints.spec.js +70 -0
  113. package/lib/test/unit/healing-trend.spec.js +118 -0
  114. package/lib/test/unit/selector-event-model.spec.js +47 -0
  115. package/lib/test/unit/selector-health-actions.spec.js +106 -0
  116. package/lib/test/unit/selector-health-detail.spec.js +131 -0
  117. package/lib/test/unit/selector-health-list.spec.js +185 -0
  118. package/lib/test/unit/selector-state-service.spec.js +74 -1
  119. package/lib/test/unit/selector-verification-job.spec.js +38 -0
  120. package/lib/test/unit/session-metrics-parse.spec.js +138 -0
  121. package/lib/test/unit/session-metrics-route.spec.js +140 -0
  122. package/lib/test/unit/session-metrics-samplers.spec.js +248 -0
  123. package/lib/test/unit/session-metrics-service.spec.js +215 -0
  124. package/lib/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +1 -1
  126. package/prisma/migrations/20261002120000_session_metric/migration.sql +17 -0
  127. package/prisma/migrations/20261002130000_selector_event/migration.sql +13 -0
  128. package/prisma/schema.prisma +43 -9
  129. package/schema.json +5 -0
  130. package/lib/public/assets/chevron-left-BSG6mcpo.js +0 -6
  131. package/lib/public/assets/funnel-OefHE-54.js +0 -6
  132. package/lib/public/assets/layers-a3GNz2DA.js +0 -6
  133. package/lib/public/assets/list-checks-DMrMAFDt.js +0 -6
  134. package/lib/public/assets/overview-CsPyPv10.js +0 -1
  135. package/lib/public/assets/selector-detail-page-pA913STP.js +0 -1
  136. package/lib/public/assets/selector-health-BlICzZlk.css +0 -1
  137. package/lib/public/assets/selector-health-CHLX8KqY.js +0 -16
  138. package/lib/public/assets/selector-health-page-vVkg8Mii.js +0 -16
  139. package/lib/public/assets/session-detail-page-BCD49zDi.js +0 -4
  140. package/lib/public/assets/stat-tile-BaE76cnK.js +0 -8
  141. package/lib/public/assets/time-B8FIgwPE.js +0 -1
  142. package/lib/src/profiling/AndroidAppProfiler.js +0 -213
  143. /package/lib/public/assets/{session-detail-page-Bzzc3bMl.css → line-chart-Bzzc3bMl.css} +0 -0
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "prisma-client-631e8540dfbb77c390eb0db189885974a87d3fa754d038cb48bcc6a9f3489314",
2
+ "name": "prisma-client-f085c861a6681acae13421cd00fb3d8201c6f55620bfaf4c83affaac2cd7b831",
3
3
  "main": "index.js",
4
4
  "types": "index.d.ts",
5
5
  "browser": "index-browser.js",
@@ -18,28 +18,28 @@ model Build {
18
18
  }
19
19
 
20
20
  model Session {
21
- id String @id
21
+ id String @id
22
22
  build_id String?
23
23
  name String?
24
- status String @default("running")
24
+ status String @default("running")
25
25
  desired_capabilities String
26
26
  session_capabilities String
27
27
  node_id String
28
28
  has_live_video Boolean
29
- video_recording_enabled Boolean @default(true)
29
+ video_recording_enabled Boolean @default(true)
30
30
  video_recording String?
31
- startTime DateTime @default(now())
31
+ startTime DateTime @default(now())
32
32
  endTime DateTime?
33
33
  failure_reason String?
34
- is_profiling_available Boolean @default(false)
34
+ is_profiling_available Boolean @default(false)
35
35
  device_info String?
36
36
  device_udid String
37
37
  device_platform String
38
38
  device_version String
39
39
  device_name String?
40
- createdAt DateTime @default(now())
41
- updatedAt DateTime @updatedAt
42
- performance_trace String? @map("performance_trace")
40
+ createdAt DateTime @default(now())
41
+ updatedAt DateTime @updatedAt
42
+ performance_trace String? @map("performance_trace")
43
43
  failure_category String?
44
44
  ai_analysis String?
45
45
  tags String?
@@ -51,7 +51,8 @@ model Session {
51
51
  user_id String?
52
52
  Log Log[]
53
53
  Profiling Profiling[]
54
- build Build? @relation(fields: [build_id], references: [id])
54
+ SessionMetric SessionMetric[]
55
+ build Build? @relation(fields: [build_id], references: [id])
55
56
  SessionLog SessionLog[]
56
57
  Recording Recording[]
57
58
 
@@ -118,6 +119,24 @@ model Profiling {
118
119
  session Session @relation(fields: [session_id], references: [id])
119
120
  }
120
121
 
122
+ /// CPU and memory sampled every 2 s while a session runs (SessionMetricsService).
123
+ model SessionMetric {
124
+ id Int @id @default(autoincrement())
125
+ session_id String
126
+ /// epoch ms
127
+ at Float
128
+ device_cpu_pct Float?
129
+ device_mem_mb Float?
130
+ device_mem_total Float?
131
+ app_cpu_pct Float?
132
+ app_mem_mb Float?
133
+ /// The package the app figures are for (Android), else null.
134
+ app_id String?
135
+ session Session @relation(fields: [session_id], references: [id], onDelete: Cascade)
136
+
137
+ @@index([session_id, at])
138
+ }
139
+
121
140
  model App {
122
141
  id String @id @default(uuid())
123
142
  name String
@@ -351,6 +370,21 @@ model SelectorState {
351
370
  @@index([fixed_at])
352
371
  }
353
372
 
373
+ // What happened to a selector, and who did it: one row per status change.
374
+ // No relation to SelectorState (unmute and cancel can delete that row; the
375
+ // history stays) or to User (a deleted user's actions stay, without a name).
376
+ model SelectorEvent {
377
+ id String @id @default(uuid())
378
+ original_strategy String
379
+ original_selector String
380
+ action String
381
+ user_id String?
382
+ reason String?
383
+ createdAt DateTime @default(now())
384
+
385
+ @@index([original_strategy, original_selector, createdAt])
386
+ }
387
+
354
388
  model Recording {
355
389
  id String @id @default(uuid())
356
390
  group_id String
@@ -211,6 +211,18 @@ exports.Prisma.ProfilingScalarFieldEnum = {
211
211
  updatedAt: 'updatedAt'
212
212
  };
213
213
 
214
+ exports.Prisma.SessionMetricScalarFieldEnum = {
215
+ id: 'id',
216
+ session_id: 'session_id',
217
+ at: 'at',
218
+ device_cpu_pct: 'device_cpu_pct',
219
+ device_mem_mb: 'device_mem_mb',
220
+ device_mem_total: 'device_mem_total',
221
+ app_cpu_pct: 'app_cpu_pct',
222
+ app_mem_mb: 'app_mem_mb',
223
+ app_id: 'app_id'
224
+ };
225
+
214
226
  exports.Prisma.AppScalarFieldEnum = {
215
227
  id: 'id',
216
228
  name: 'name',
@@ -399,6 +411,16 @@ exports.Prisma.SelectorStateScalarFieldEnum = {
399
411
  updatedAt: 'updatedAt'
400
412
  };
401
413
 
414
+ exports.Prisma.SelectorEventScalarFieldEnum = {
415
+ id: 'id',
416
+ original_strategy: 'original_strategy',
417
+ original_selector: 'original_selector',
418
+ action: 'action',
419
+ user_id: 'user_id',
420
+ reason: 'reason',
421
+ createdAt: 'createdAt'
422
+ };
423
+
402
424
  exports.Prisma.RecordingScalarFieldEnum = {
403
425
  id: 'id',
404
426
  group_id: 'group_id',
@@ -510,6 +532,7 @@ exports.Prisma.ModelName = {
510
532
  SessionLog: 'SessionLog',
511
533
  Log: 'Log',
512
534
  Profiling: 'Profiling',
535
+ SessionMetric: 'SessionMetric',
513
536
  App: 'App',
514
537
  Device: 'Device',
515
538
  PendingSession: 'PendingSession',
@@ -522,6 +545,7 @@ exports.Prisma.ModelName = {
522
545
  ApiKey: 'ApiKey',
523
546
  Team: 'Team',
524
547
  SelectorState: 'SelectorState',
548
+ SelectorEvent: 'SelectorEvent',
525
549
  Recording: 'Recording',
526
550
  Bookmark: 'Bookmark',
527
551
  Annotation: 'Annotation',
package/lib/src/prisma.js CHANGED
@@ -38,6 +38,8 @@ const MODEL_DELEGATES = new Set([
38
38
  'lease', 'portLease', 'apiKey', 'selectorState', 'user', 'userSession',
39
39
  'passwordResetToken', 'team', 'teamMember', 'eventLog', 'project', 'recording', 'bookmark',
40
40
  'annotation',
41
+ 'sessionMetric',
42
+ 'selectorEvent',
41
43
  ]);
42
44
  /** Cache of plain-object wrappers, keyed by model name. */
43
45
  const _modelWrappers = new Map();
@@ -308,6 +308,7 @@ let CleanupService = class CleanupService {
308
308
  prisma_1.prisma.sessionLog.deleteMany({ where: { session_id: sessionId } }),
309
309
  prisma_1.prisma.log.deleteMany({ where: { session_id: sessionId } }),
310
310
  prisma_1.prisma.profiling.deleteMany({ where: { session_id: sessionId } }),
311
+ prisma_1.prisma.sessionMetric.deleteMany({ where: { session_id: sessionId } }),
311
312
  ]);
312
313
  });
313
314
  }
@@ -114,7 +114,7 @@ let NotificationService = class NotificationService {
114
114
  }
115
115
  sendSlackMessage(url, eventType, payload) {
116
116
  return __awaiter(this, void 0, void 0, function* () {
117
- var _a, _b, _c, _d;
117
+ var _a, _b, _c;
118
118
  let text = '';
119
119
  let color = '#36a64f'; // green
120
120
  switch (eventType) {
@@ -135,15 +135,14 @@ let NotificationService = class NotificationService {
135
135
  // formatting end-to-end.
136
136
  const totalHeals = (_a = payload.totalHeals) !== null && _a !== void 0 ? _a : 0;
137
137
  const distinctSelectors = (_b = payload.distinctSelectors) !== null && _b !== void 0 ? _b : 0;
138
- const estCostUsd = (_c = payload.estCostUsd) !== null && _c !== void 0 ? _c : 0;
139
- const windowDays = (_d = payload.windowDays) !== null && _d !== void 0 ? _d : 30;
138
+ const windowDays = (_c = payload.windowDays) !== null && _c !== void 0 ? _c : 30;
140
139
  const top = Array.isArray(payload.hotspots) ? payload.hotspots : [];
141
140
  const lines = top
142
141
  .slice(0, 5)
143
142
  .map((h, i) => `${i + 1}. *${h.healCount}× healed* — \`${h.originalSelector}\`` +
144
143
  (h.suggestedRewrite ? `\n ↳ rewrite: \`${h.suggestedRewrite}\`` : ''))
145
144
  .join('\n');
146
- const summary = `🩺 *Selector Health digest* — last ${windowDays}d\n${totalHeals} heals across ${distinctSelectors} selectors · est. $${estCostUsd.toFixed(2)}`;
145
+ const summary = `🩺 *Selector Health digest* — last ${windowDays}d\n${totalHeals} heals across ${distinctSelectors} selectors`;
147
146
  const body = {
148
147
  text: summary,
149
148
  attachments: [
@@ -22,6 +22,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
22
22
  };
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.SelectorStateService = exports.SelectorStateConflictError = void 0;
25
+ exports.selectorEventData = selectorEventData;
25
26
  const typedi_1 = require("typedi");
26
27
  const prisma_1 = require("../prisma");
27
28
  const SocketServer_1 = require("./SocketServer");
@@ -50,6 +51,18 @@ function whereTuple(strategy, selector) {
50
51
  },
51
52
  };
52
53
  }
54
+ /** The SelectorEvent row for one change. */
55
+ function selectorEventData(tuple, action, userId, reason) {
56
+ return {
57
+ data: {
58
+ original_strategy: tuple.strategy,
59
+ original_selector: tuple.selector,
60
+ action,
61
+ user_id: userId !== null && userId !== void 0 ? userId : null,
62
+ reason: reason !== null && reason !== void 0 ? reason : null,
63
+ },
64
+ };
65
+ }
53
66
  /**
54
67
  * Convert a SelectorState row into a plain object with ISO-8601 timestamps
55
68
  * suitable for socket.io broadcast and JSON serialization.
@@ -76,7 +89,8 @@ function serialize(row) {
76
89
  * Owns SelectorState lifecycle transitions. Every transition is idempotent
77
90
  * where the spec allows, emits the corresponding `SELECTOR_*` socket event,
78
91
  * and uses lazy row creation — rows only exist once a user has taken an
79
- * action against the selector.
92
+ * action against the selector. Each change and its SelectorEvent are one
93
+ * transaction, so the status and its history can't disagree.
80
94
  *
81
95
  * The constructor accepts both prisma + socket as parameters (defaulting to
82
96
  * the real implementations) so tests can pass Sinon stubs without going
@@ -94,33 +108,37 @@ let SelectorStateService = class SelectorStateService {
94
108
  */
95
109
  markFixed(ctx) {
96
110
  return __awaiter(this, void 0, void 0, function* () {
97
- const existing = yield this.prisma.selectorState.findUnique({
98
- where: whereTuple(ctx.strategy, ctx.selector),
99
- });
100
- if (existing && existing.status === 'muted') {
101
- throw new SelectorStateConflictError(`Cannot markFixed on a muted selector (${ctx.strategy}=${ctx.selector})`, existing.status);
102
- }
103
- const now = new Date();
104
- const row = yield this.prisma.selectorState.upsert({
105
- where: whereTuple(ctx.strategy, ctx.selector),
106
- create: {
107
- original_strategy: ctx.strategy,
108
- original_selector: ctx.selector,
109
- status: 'pending',
110
- fixed_at: now,
111
- fixed_by_api_key: ctx.apiKeyId,
112
- clean_builds_count: 0,
113
- last_event_at: now,
114
- },
115
- update: {
116
- status: 'pending',
117
- fixed_at: now,
118
- fixed_by_api_key: ctx.apiKeyId,
119
- clean_builds_count: 0,
120
- resolved_at: null,
121
- last_event_at: now,
122
- },
123
- });
111
+ const row = yield this.prisma.$transaction((tx) => __awaiter(this, void 0, void 0, function* () {
112
+ const existing = yield tx.selectorState.findUnique({
113
+ where: whereTuple(ctx.strategy, ctx.selector),
114
+ });
115
+ if (existing && existing.status === 'muted') {
116
+ throw new SelectorStateConflictError(`Cannot markFixed on a muted selector (${ctx.strategy}=${ctx.selector})`, existing.status);
117
+ }
118
+ const now = new Date();
119
+ const upserted = yield tx.selectorState.upsert({
120
+ where: whereTuple(ctx.strategy, ctx.selector),
121
+ create: {
122
+ original_strategy: ctx.strategy,
123
+ original_selector: ctx.selector,
124
+ status: 'pending',
125
+ fixed_at: now,
126
+ fixed_by_api_key: ctx.apiKeyId,
127
+ clean_builds_count: 0,
128
+ last_event_at: now,
129
+ },
130
+ update: {
131
+ status: 'pending',
132
+ fixed_at: now,
133
+ fixed_by_api_key: ctx.apiKeyId,
134
+ clean_builds_count: 0,
135
+ resolved_at: null,
136
+ last_event_at: now,
137
+ },
138
+ });
139
+ yield tx.selectorEvent.create(selectorEventData(ctx, 'marked_fixed', ctx.userId));
140
+ return upserted;
141
+ }));
124
142
  scopedLog.info(`markFixed: ${ctx.strategy}=${ctx.selector} → pending (api_key=${ctx.apiKeyId})`);
125
143
  this.socket.emitToDashboard(SocketEvents_1.SocketEvents.SELECTOR_FIXED, serialize(row));
126
144
  return row;
@@ -128,29 +146,33 @@ let SelectorStateService = class SelectorStateService {
128
146
  }
129
147
  /**
130
148
  * Silence a selector. Idempotent — re-muting an already-muted selector
131
- * just refreshes muted_at / muted_by_api_key.
149
+ * just refreshes muted_at / muted_by_api_key, and records the new reason.
132
150
  */
133
151
  mute(ctx) {
134
152
  return __awaiter(this, void 0, void 0, function* () {
135
- const now = new Date();
136
- const row = yield this.prisma.selectorState.upsert({
137
- where: whereTuple(ctx.strategy, ctx.selector),
138
- create: {
139
- original_strategy: ctx.strategy,
140
- original_selector: ctx.selector,
141
- status: 'muted',
142
- muted_at: now,
143
- muted_by_api_key: ctx.apiKeyId,
144
- last_event_at: now,
145
- },
146
- update: {
147
- // Preserve any prior fixed_at / resolved_at history (Pending → Muted spec).
148
- status: 'muted',
149
- muted_at: now,
150
- muted_by_api_key: ctx.apiKeyId,
151
- last_event_at: now,
152
- },
153
- });
153
+ const row = yield this.prisma.$transaction((tx) => __awaiter(this, void 0, void 0, function* () {
154
+ const now = new Date();
155
+ const upserted = yield tx.selectorState.upsert({
156
+ where: whereTuple(ctx.strategy, ctx.selector),
157
+ create: {
158
+ original_strategy: ctx.strategy,
159
+ original_selector: ctx.selector,
160
+ status: 'muted',
161
+ muted_at: now,
162
+ muted_by_api_key: ctx.apiKeyId,
163
+ last_event_at: now,
164
+ },
165
+ update: {
166
+ // Preserve any prior fixed_at / resolved_at history (Pending → Muted spec).
167
+ status: 'muted',
168
+ muted_at: now,
169
+ muted_by_api_key: ctx.apiKeyId,
170
+ last_event_at: now,
171
+ },
172
+ });
173
+ yield tx.selectorEvent.create(selectorEventData(ctx, 'muted', ctx.userId, ctx.reason));
174
+ return upserted;
175
+ }));
154
176
  scopedLog.info(`mute: ${ctx.strategy}=${ctx.selector} → muted (api_key=${ctx.apiKeyId})`);
155
177
  this.socket.emitToDashboard(SocketEvents_1.SocketEvents.SELECTOR_MUTED, serialize(row));
156
178
  return row;
@@ -167,37 +189,45 @@ let SelectorStateService = class SelectorStateService {
167
189
  * - `resolved_at != null` (it has reached the resolved terminal state).
168
190
  *
169
191
  * Calling `unmute` on a row whose status is not `'muted'` (including a
170
- * missing row) is a silent no-op: the existing row is returned unchanged
171
- * and no socket event is emitted.
192
+ * missing row) is a silent no-op: the existing row is returned unchanged,
193
+ * nothing is recorded and no socket event is emitted.
172
194
  */
173
195
  unmute(ctx) {
174
196
  return __awaiter(this, void 0, void 0, function* () {
175
- const existing = yield this.prisma.selectorState.findUnique({
176
- where: whereTuple(ctx.strategy, ctx.selector),
177
- });
178
- if (!existing || existing.status !== 'muted') {
179
- return existing;
180
- }
181
- const hasOtherHistory = existing.regression_count > 0 || existing.fixed_at !== null || existing.resolved_at !== null;
182
- let result;
183
- if (!hasOtherHistory) {
184
- yield this.prisma.selectorState.delete({
197
+ const outcome = yield this.prisma.$transaction((tx) => __awaiter(this, void 0, void 0, function* () {
198
+ const existing = yield tx.selectorState.findUnique({
185
199
  where: whereTuple(ctx.strategy, ctx.selector),
186
200
  });
187
- result = null;
188
- }
189
- else {
190
- const now = new Date();
191
- result = yield this.prisma.selectorState.update({
192
- where: whereTuple(ctx.strategy, ctx.selector),
193
- data: {
194
- status: 'active',
195
- muted_at: null,
196
- muted_by_api_key: null,
197
- last_event_at: now,
198
- },
199
- });
200
- }
201
+ if (!existing || existing.status !== 'muted') {
202
+ return { changed: false, row: existing };
203
+ }
204
+ const hasOtherHistory = existing.regression_count > 0 ||
205
+ existing.fixed_at !== null ||
206
+ existing.resolved_at !== null;
207
+ let result;
208
+ if (!hasOtherHistory) {
209
+ yield tx.selectorState.delete({
210
+ where: whereTuple(ctx.strategy, ctx.selector),
211
+ });
212
+ result = null;
213
+ }
214
+ else {
215
+ result = yield tx.selectorState.update({
216
+ where: whereTuple(ctx.strategy, ctx.selector),
217
+ data: {
218
+ status: 'active',
219
+ muted_at: null,
220
+ muted_by_api_key: null,
221
+ last_event_at: new Date(),
222
+ },
223
+ });
224
+ }
225
+ yield tx.selectorEvent.create(selectorEventData(ctx, 'unmuted', ctx.userId));
226
+ return { changed: true, row: result };
227
+ }));
228
+ if (!outcome.changed)
229
+ return outcome.row;
230
+ const result = outcome.row;
201
231
  scopedLog.info(`unmute: ${ctx.strategy}=${ctx.selector} → ${result ? 'active' : 'deleted'} (api_key=${ctx.apiKeyId})`);
202
232
  // Emit the post-state. When the row was deleted, surface the prior tuple
203
233
  // so the dashboard can drop it from the muted list.
@@ -218,33 +248,36 @@ let SelectorStateService = class SelectorStateService {
218
248
  */
219
249
  cancelVerification(ctx) {
220
250
  return __awaiter(this, void 0, void 0, function* () {
221
- const existing = yield this.prisma.selectorState.findUnique({
222
- where: whereTuple(ctx.strategy, ctx.selector),
223
- });
224
- if (!existing || existing.status !== 'pending') {
225
- throw new SelectorStateConflictError(`cancelVerification requires status=pending (${ctx.strategy}=${ctx.selector})`, existing ? existing.status : 'none');
226
- }
227
- const hasOtherHistory = existing.regression_count > 0 || existing.resolved_at !== null;
228
- let result;
229
- if (!hasOtherHistory) {
230
- yield this.prisma.selectorState.delete({
231
- where: whereTuple(ctx.strategy, ctx.selector),
232
- });
233
- result = null;
234
- }
235
- else {
236
- const now = new Date();
237
- result = yield this.prisma.selectorState.update({
251
+ const result = yield this.prisma.$transaction((tx) => __awaiter(this, void 0, void 0, function* () {
252
+ const existing = yield tx.selectorState.findUnique({
238
253
  where: whereTuple(ctx.strategy, ctx.selector),
239
- data: {
240
- status: 'active',
241
- fixed_at: null,
242
- fixed_by_api_key: null,
243
- clean_builds_count: 0,
244
- last_event_at: now,
245
- },
246
254
  });
247
- }
255
+ if (!existing || existing.status !== 'pending') {
256
+ throw new SelectorStateConflictError(`cancelVerification requires status=pending (${ctx.strategy}=${ctx.selector})`, existing ? existing.status : 'none');
257
+ }
258
+ const hasOtherHistory = existing.regression_count > 0 || existing.resolved_at !== null;
259
+ let row;
260
+ if (!hasOtherHistory) {
261
+ yield tx.selectorState.delete({
262
+ where: whereTuple(ctx.strategy, ctx.selector),
263
+ });
264
+ row = null;
265
+ }
266
+ else {
267
+ row = yield tx.selectorState.update({
268
+ where: whereTuple(ctx.strategy, ctx.selector),
269
+ data: {
270
+ status: 'active',
271
+ fixed_at: null,
272
+ fixed_by_api_key: null,
273
+ clean_builds_count: 0,
274
+ last_event_at: new Date(),
275
+ },
276
+ });
277
+ }
278
+ yield tx.selectorEvent.create(selectorEventData(ctx, 'verification_cancelled', ctx.userId));
279
+ return row;
280
+ }));
248
281
  scopedLog.info(`cancelVerification: ${ctx.strategy}=${ctx.selector} → ${result ? 'active' : 'deleted'} (api_key=${ctx.apiKeyId})`);
249
282
  this.socket.emitToDashboard(SocketEvents_1.SocketEvents.SELECTOR_CANCELLED, result
250
283
  ? serialize(result)
@@ -260,8 +293,8 @@ let SelectorStateService = class SelectorStateService {
260
293
  * Heal-write hook called from the heal write path when `is_healed=true` is
261
294
  * about to be persisted. If the (strategy, selector) row is in `'pending'`
262
295
  * or `'resolved'` state, transitions it back to `'active'`, increments
263
- * `regression_count`, and emits `SELECTOR_REGRESSED`. No-op if the row is
264
- * absent, muted, or already active.
296
+ * `regression_count`, records `broke_again`, and emits `SELECTOR_REGRESSED`.
297
+ * No-op if the row is absent, muted, or already active.
265
298
  *
266
299
  * Return value: provided for testing/integration scenarios. Production
267
300
  * callers in the heal write path (`event-manager.ts`) should treat this
@@ -270,6 +303,8 @@ let SelectorStateService = class SelectorStateService {
270
303
  */
271
304
  onHealRecorded(ctx) {
272
305
  return __awaiter(this, void 0, void 0, function* () {
306
+ // Read outside a transaction: this runs on every heal, and almost every
307
+ // heal changes nothing.
273
308
  const existing = yield this.prisma.selectorState.findUnique({
274
309
  where: whereTuple(ctx.strategy, ctx.selector),
275
310
  });
@@ -278,18 +313,21 @@ let SelectorStateService = class SelectorStateService {
278
313
  if (existing.status !== 'pending' && existing.status !== 'resolved') {
279
314
  return existing;
280
315
  }
281
- const now = new Date();
282
- const row = yield this.prisma.selectorState.update({
283
- where: whereTuple(ctx.strategy, ctx.selector),
284
- data: {
285
- status: 'active',
286
- fixed_at: null,
287
- resolved_at: null,
288
- regression_count: { increment: 1 },
289
- clean_builds_count: 0,
290
- last_event_at: now,
291
- },
292
- });
316
+ const row = yield this.prisma.$transaction((tx) => __awaiter(this, void 0, void 0, function* () {
317
+ const updated = yield tx.selectorState.update({
318
+ where: whereTuple(ctx.strategy, ctx.selector),
319
+ data: {
320
+ status: 'active',
321
+ fixed_at: null,
322
+ resolved_at: null,
323
+ regression_count: { increment: 1 },
324
+ clean_builds_count: 0,
325
+ last_event_at: new Date(),
326
+ },
327
+ });
328
+ yield tx.selectorEvent.create(selectorEventData(ctx, 'broke_again'));
329
+ return updated;
330
+ }));
293
331
  scopedLog.warn(`onHealRecorded: ${ctx.strategy}=${ctx.selector} regressed from ${existing.status} (session=${ctx.sessionId})`);
294
332
  this.socket.emitToDashboard(SocketEvents_1.SocketEvents.SELECTOR_REGRESSED, serialize(row));
295
333
  return row;
@@ -27,6 +27,7 @@ const prisma_1 = require("../prisma");
27
27
  const SocketServer_1 = require("./SocketServer");
28
28
  const SocketEvents_1 = require("../enums/SocketEvents");
29
29
  const logger_1 = __importDefault(require("../logger"));
30
+ const SelectorStateService_1 = require("./SelectorStateService");
30
31
  const log = logger_1.default.scope('SelectorVerification');
31
32
  /**
32
33
  * Threshold of distinct clean build_ids required to promote a Pending
@@ -109,15 +110,19 @@ let SelectorVerificationJob = class SelectorVerificationJob {
109
110
  promoteToResolved(row) {
110
111
  return __awaiter(this, void 0, void 0, function* () {
111
112
  const now = new Date();
112
- const updated = yield this.prisma.selectorState.update({
113
- where: { id: row.id },
114
- data: {
115
- status: 'resolved',
116
- resolved_at: now,
117
- clean_builds_count: CLEAN_BUILDS_TO_RESOLVE,
118
- last_event_at: now,
119
- },
120
- });
113
+ const updated = yield this.prisma.$transaction((tx) => __awaiter(this, void 0, void 0, function* () {
114
+ const promoted = yield tx.selectorState.update({
115
+ where: { id: row.id },
116
+ data: {
117
+ status: 'resolved',
118
+ resolved_at: now,
119
+ clean_builds_count: CLEAN_BUILDS_TO_RESOLVE,
120
+ last_event_at: now,
121
+ },
122
+ });
123
+ yield tx.selectorEvent.create((0, SelectorStateService_1.selectorEventData)({ strategy: row.original_strategy, selector: row.original_selector }, 'verified'));
124
+ return promoted;
125
+ }));
121
126
  log.info(`[${row.id}] promoted to resolved (${row.original_strategy}=${row.original_selector})`);
122
127
  this.socket.emitToDashboard(SocketEvents_1.SocketEvents.SELECTOR_RESOLVED, this.serialize(updated));
123
128
  });