@oneuptime/common 11.5.12 → 11.6.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 (193) hide show
  1. package/Models/DatabaseModels/AIInsight.ts +30 -0
  2. package/Models/DatabaseModels/EnterpriseLicense.ts +21 -0
  3. package/Models/DatabaseModels/EnterpriseLicenseInstance.ts +25 -0
  4. package/Models/DatabaseModels/GlobalConfig.ts +76 -0
  5. package/Models/DatabaseModels/Project.ts +30 -0
  6. package/Models/DatabaseModels/TelemetryException.ts +128 -0
  7. package/Server/API/AIAgentDataAPI.ts +129 -5
  8. package/Server/API/AIInvestigationAPI.ts +87 -0
  9. package/Server/API/EnterpriseLicenseAPI.ts +57 -0
  10. package/Server/API/GlobalConfigAPI.ts +62 -1
  11. package/Server/EnvironmentConfig.ts +22 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.ts +56 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.ts +38 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.ts +25 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  16. package/Server/Services/AIRunService.ts +8 -0
  17. package/Server/Services/AnalyticsDatabaseService.ts +48 -45
  18. package/Server/Services/DatabaseService.ts +66 -1
  19. package/Server/Services/TelemetryExceptionService.ts +320 -24
  20. package/Server/Services/WorkflowService.ts +100 -39
  21. package/Server/Types/Workflow/Components/Schedule.ts +57 -32
  22. package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +15 -4
  23. package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +15 -4
  24. package/Server/Utils/AI/SRE/Insights/InsightScanner.ts +222 -24
  25. package/Server/Utils/AI/SRE/Insights/InsightTriageRunner.ts +225 -9
  26. package/Server/Utils/AI/SRE/SubjectCodeFixRun.ts +39 -0
  27. package/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.ts +114 -0
  28. package/Server/Utils/AnalyticsDatabase/InsertDedupContext.ts +59 -0
  29. package/Server/Utils/Express.ts +13 -0
  30. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +222 -0
  31. package/Server/Utils/Telemetry/TelemetryFanInWriter.ts +799 -0
  32. package/Server/Utils/Telemetry/TelemetryWriterClient.ts +263 -0
  33. package/Server/Utils/Telemetry/TelemetryWriterServer.ts +240 -0
  34. package/Server/Utils/Telemetry/TelemetryWriterShedMetrics.ts +93 -0
  35. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +232 -0
  36. package/Tests/Server/Services/TelemetryExceptionCodeFixRun.test.ts +205 -1
  37. package/Tests/Server/Services/UpdateOneByIdKeepsRowId.test.ts +107 -0
  38. package/Tests/Server/Services/WorkflowService.test.ts +308 -0
  39. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +150 -141
  40. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +277 -1
  41. package/Tests/Server/Utils/Attribution.test.ts +203 -0
  42. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +70 -0
  43. package/Tests/Server/Utils/InsightTriageClassification.test.ts +57 -0
  44. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +183 -0
  45. package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +340 -0
  46. package/Tests/Server/Utils/Monitor/Criteria/ExceptionMonitorCriteria.test.ts +87 -0
  47. package/Tests/Server/Utils/Monitor/Criteria/LogMonitorCriteria.test.ts +165 -0
  48. package/Tests/Server/Utils/Monitor/Criteria/TraceMonitorCriteria.test.ts +86 -0
  49. package/Tests/Server/Utils/Telemetry/TelemetryFanInWriter.test.ts +1565 -0
  50. package/Tests/Server/Utils/Telemetry/TelemetryWriterClient.test.ts +313 -0
  51. package/Tests/Server/Utils/Telemetry/TelemetryWriterServer.test.ts +270 -0
  52. package/Tests/Server/Utils/Telemetry/TelemetryWriterShedMetrics.test.ts +127 -0
  53. package/Tests/Server/Utils/TelemetryImprovementTaskTrigger.test.ts +141 -0
  54. package/Tests/Types/AI/CodeFixTaskType.test.ts +3 -0
  55. package/Tests/Types/Monitor/MonitorCriteriaMetricVariables.test.ts +228 -0
  56. package/Tests/Types/Monitor/MonitorStepDefaultTelemetryConfig.test.ts +169 -0
  57. package/Tests/Types/Monitor/MonitorStepExceptionMonitor.test.ts +289 -0
  58. package/Tests/Types/Monitor/MonitorStepLogMonitor.test.ts +231 -0
  59. package/Tests/Types/Monitor/MonitorStepMetricViewConfigUtil.test.ts +245 -0
  60. package/Tests/Types/Monitor/MonitorStepNetworkDeviceMonitor.test.ts +155 -0
  61. package/Tests/Types/Monitor/MonitorStepTelemetrySerialization.test.ts +141 -0
  62. package/Tests/Types/Monitor/MonitorStepTraceMonitor.test.ts +137 -0
  63. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +311 -0
  64. package/Tests/UI/Components/ModelTableSelectFromColumns.test.ts +259 -0
  65. package/Tests/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.test.ts +461 -0
  66. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +2852 -0
  67. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +758 -0
  68. package/Tests/UI/Utils/NotificationMethodUtil.test.ts +564 -0
  69. package/Tests/Utils/Array.test.ts +156 -0
  70. package/Tests/Utils/CronTab.test.ts +199 -0
  71. package/Tests/Utils/ModelImportExport.test.ts +101 -0
  72. package/Tests/Utils/Number.test.ts +179 -0
  73. package/Tests/Utils/VersionUtil.test.ts +348 -0
  74. package/Tests/jest.setup.ts +1 -0
  75. package/Types/AI/CodeFixTaskContext.ts +6 -0
  76. package/Types/AI/CodeFixTaskType.ts +38 -1
  77. package/Types/AI/ExceptionAIClassification.ts +27 -0
  78. package/Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary.ts +6 -0
  79. package/Types/Log/LogScrubPatternType.ts +7 -0
  80. package/Types/Monitor/MonitorStep.ts +24 -4
  81. package/Types/Monitor/MonitorStepMetricViewConfigUtil.ts +105 -0
  82. package/Types/Trace/TraceScrubPatternType.ts +7 -0
  83. package/UI/Components/EditionLabel/EditionLabel.tsx +445 -12
  84. package/UI/Components/ModelTable/BaseModelTable.tsx +15 -46
  85. package/UI/Components/ModelTable/SelectFromColumns.ts +129 -0
  86. package/UI/Components/Workflow/ArgumentsForm.tsx +68 -30
  87. package/UI/Components/Workflow/CronScheduleField.tsx +411 -0
  88. package/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.ts +176 -0
  89. package/UI/Utils/NotificationMethodUtil.ts +310 -0
  90. package/Utils/CronTab.ts +823 -0
  91. package/Utils/ModelImportExport.ts +21 -6
  92. package/Utils/VersionUtil.ts +260 -0
  93. package/build/dist/Models/DatabaseModels/AIInsight.js +31 -0
  94. package/build/dist/Models/DatabaseModels/AIInsight.js.map +1 -1
  95. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +22 -0
  96. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  97. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js +26 -0
  98. package/build/dist/Models/DatabaseModels/EnterpriseLicenseInstance.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/GlobalConfig.js +81 -0
  100. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/Project.js +31 -0
  102. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  103. package/build/dist/Models/DatabaseModels/TelemetryException.js +134 -0
  104. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  105. package/build/dist/Server/API/AIAgentDataAPI.js +97 -8
  106. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  107. package/build/dist/Server/API/AIInvestigationAPI.js +55 -0
  108. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  109. package/build/dist/Server/API/EnterpriseLicenseAPI.js +47 -0
  110. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  111. package/build/dist/Server/API/GlobalConfigAPI.js +52 -1
  112. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  113. package/build/dist/Server/EnvironmentConfig.js +17 -0
  114. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js +33 -0
  116. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js +18 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784659816363-AddInstanceVersionAndLatestReleaseColumns.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js +14 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784705487674-AddEnterpriseLicenseEvaluationColumns.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AIRunService.js +9 -2
  124. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  125. package/build/dist/Server/Services/AnalyticsDatabaseService.js +44 -16
  126. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  127. package/build/dist/Server/Services/DatabaseService.js +53 -1
  128. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  129. package/build/dist/Server/Services/TelemetryExceptionService.js +263 -22
  130. package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
  131. package/build/dist/Server/Services/WorkflowService.js +80 -30
  132. package/build/dist/Server/Services/WorkflowService.js.map +1 -1
  133. package/build/dist/Server/Types/Workflow/Components/Schedule.js +44 -19
  134. package/build/dist/Server/Types/Workflow/Components/Schedule.js.map +1 -1
  135. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +12 -4
  136. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
  137. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +12 -4
  138. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
  139. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js +193 -22
  140. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js.map +1 -1
  141. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js +178 -9
  142. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js.map +1 -1
  143. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js +37 -0
  144. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js.map +1 -1
  145. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js +98 -0
  146. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js.map +1 -0
  147. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js +24 -0
  148. package/build/dist/Server/Utils/AnalyticsDatabase/InsertDedupContext.js.map +1 -0
  149. package/build/dist/Server/Utils/Express.js +12 -0
  150. package/build/dist/Server/Utils/Express.js.map +1 -1
  151. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +149 -0
  152. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -0
  153. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js +496 -0
  154. package/build/dist/Server/Utils/Telemetry/TelemetryFanInWriter.js.map +1 -0
  155. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js +165 -0
  156. package/build/dist/Server/Utils/Telemetry/TelemetryWriterClient.js.map +1 -0
  157. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js +136 -0
  158. package/build/dist/Server/Utils/Telemetry/TelemetryWriterServer.js.map +1 -0
  159. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js +76 -0
  160. package/build/dist/Server/Utils/Telemetry/TelemetryWriterShedMetrics.js.map +1 -0
  161. package/build/dist/Types/AI/CodeFixTaskType.js +38 -1
  162. package/build/dist/Types/AI/CodeFixTaskType.js.map +1 -1
  163. package/build/dist/Types/AI/ExceptionAIClassification.js +28 -0
  164. package/build/dist/Types/AI/ExceptionAIClassification.js.map +1 -0
  165. package/build/dist/Types/Log/LogScrubPatternType.js +7 -0
  166. package/build/dist/Types/Log/LogScrubPatternType.js.map +1 -1
  167. package/build/dist/Types/Monitor/MonitorStep.js +20 -4
  168. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  169. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js +73 -0
  170. package/build/dist/Types/Monitor/MonitorStepMetricViewConfigUtil.js.map +1 -0
  171. package/build/dist/Types/Trace/TraceScrubPatternType.js +7 -0
  172. package/build/dist/Types/Trace/TraceScrubPatternType.js.map +1 -1
  173. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +261 -13
  174. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  175. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +12 -37
  176. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  177. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js +82 -0
  178. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js.map +1 -0
  179. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +30 -6
  180. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  181. package/build/dist/UI/Components/Workflow/CronScheduleField.js +209 -0
  182. package/build/dist/UI/Components/Workflow/CronScheduleField.js.map +1 -0
  183. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js +129 -0
  184. package/build/dist/UI/Utils/Breadcrumb/BreadcrumbTrailResolver.js.map +1 -0
  185. package/build/dist/UI/Utils/NotificationMethodUtil.js +189 -0
  186. package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -0
  187. package/build/dist/Utils/CronTab.js +609 -0
  188. package/build/dist/Utils/CronTab.js.map +1 -0
  189. package/build/dist/Utils/ModelImportExport.js +20 -6
  190. package/build/dist/Utils/ModelImportExport.js.map +1 -1
  191. package/build/dist/Utils/VersionUtil.js +193 -0
  192. package/build/dist/Utils/VersionUtil.js.map +1 -0
  193. package/package.json +1 -1
@@ -1743,10 +1743,67 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1743
1743
  });
1744
1744
  }
1745
1745
 
1746
+ /*
1747
+ * Update `data` may arrive as a full model instance rather than a plain
1748
+ * partial — the QueryDeepPartialEntity type structurally admits both, and
1749
+ * callers do construct `new Model()` payloads. A model instance carries
1750
+ * non-column own properties from DatabaseBaseModel (every column
1751
+ * initializer plus `isPermissionIf = {}`), and _updateBy turns every data
1752
+ * key into a select column for its internal find, so the extra keys made
1753
+ * that find throw `TableColumnMetadata not found for isPermissionIf
1754
+ * column` and fail the whole update. Strip a model instance down to its
1755
+ * set table columns; plain objects pass through untouched so a typo'd
1756
+ * column name in a literal still fails loudly instead of being silently
1757
+ * dropped.
1758
+ *
1759
+ * `_id`, `createdAt`, `updatedAt` and `version` are dropped for model
1760
+ * instances the same way BaseAPI drops them from client payloads: the row
1761
+ * is located by the update query (spreading a foreign `_id` into the save
1762
+ * payload would redirect the write), timestamps are database-managed, and
1763
+ * `version` is TypeORM's optimistic-concurrency counter.
1764
+ */
1765
+ public sanitizeUpdateData(
1766
+ data: UpdateBy<TBaseModel>["data"],
1767
+ ): UpdateBy<TBaseModel>["data"] {
1768
+ if (!(data instanceof BaseModel)) {
1769
+ return data;
1770
+ }
1771
+
1772
+ const plainData: JSONObject = {};
1773
+
1774
+ for (const key of Object.keys(data)) {
1775
+ if (!this.model.isTableColumn(key)) {
1776
+ continue;
1777
+ }
1778
+
1779
+ if (
1780
+ key === "_id" ||
1781
+ key === "createdAt" ||
1782
+ key === "updatedAt" ||
1783
+ key === "version"
1784
+ ) {
1785
+ continue;
1786
+ }
1787
+
1788
+ const value: JSONValue = (data as any)[key];
1789
+
1790
+ // Unset columns must not become writes (or select columns).
1791
+ if (value === undefined) {
1792
+ continue;
1793
+ }
1794
+
1795
+ plainData[key] = value;
1796
+ }
1797
+
1798
+ return plainData as UpdateBy<TBaseModel>["data"];
1799
+ }
1800
+
1746
1801
  private async _updateBy(updateBy: UpdateBy<TBaseModel>): Promise<number> {
1747
1802
  try {
1748
1803
  this.setTelemetryContextFromProps(updateBy.props);
1749
1804
 
1805
+ updateBy.data = this.sanitizeUpdateData(updateBy.data);
1806
+
1750
1807
  const onUpdate: OnUpdate<TBaseModel> = updateBy.props.ignoreHooks
1751
1808
  ? { updateBy, carryForward: [] }
1752
1809
  : await this.onBeforeUpdate(updateBy);
@@ -1830,9 +1887,17 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1830
1887
  });
1831
1888
 
1832
1889
  for (const item of items) {
1890
+ /*
1891
+ * _id must be set AFTER the spread: update data can carry an
1892
+ * explicit `_id: undefined` (sanitizeUpdateData strips it from model
1893
+ * instances, but a plain object can still hold it), and spreading it
1894
+ * after _id would clobber the located row's id — save() then sees no
1895
+ * primary key, INSERTs instead of updating, and dies on the first
1896
+ * NOT NULL column.
1897
+ */
1833
1898
  const updatedItem: any = {
1834
- _id: item._id!,
1835
1899
  ...data,
1900
+ _id: item._id!,
1836
1901
  } as any;
1837
1902
 
1838
1903
  logger.debug("Updated Item", {
@@ -28,6 +28,12 @@ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
28
28
  import Query from "../Types/Database/Query";
29
29
  import Includes from "../../Types/BaseDatabase/Includes";
30
30
  import logger from "../Utils/Logger";
31
+ import AIAgentTaskPullRequest from "../../Models/DatabaseModels/AIAgentTaskPullRequest";
32
+ import AIAgentTaskPullRequestService from "./AIAgentTaskPullRequestService";
33
+ import PullRequestState from "../../Types/CodeRepository/PullRequestState";
34
+ import { normalizeExceptionText } from "../Utils/Telemetry/ExceptionSanitizer";
35
+ import Semaphore, { SemaphoreMutex } from "../Infrastructure/Semaphore";
36
+ import LIMIT_MAX from "../../Types/Database/LimitMax";
31
37
 
32
38
  /*
33
39
  * Hard cap on the fingerprint NOT IN list handed to the ClickHouse count
@@ -221,6 +227,11 @@ export class Service extends DatabaseService<Model> {
221
227
  select: {
222
228
  _id: true,
223
229
  projectId: true,
230
+ message: true,
231
+ exceptionType: true,
232
+ isResolved: true,
233
+ isArchived: true,
234
+ aiFixDeclinedAt: true,
224
235
  },
225
236
  props,
226
237
  });
@@ -229,6 +240,45 @@ export class Service extends DatabaseService<Model> {
229
240
  throw new BadDataException("Telemetry Exception not found");
230
241
  }
231
242
 
243
+ /*
244
+ * Server-side lifecycle gate (the dashboard hides the button for
245
+ * resolved exceptions, but the API must enforce it — the automatic
246
+ * insight lane and any direct API caller land here too).
247
+ */
248
+ if (telemetryException.isResolved) {
249
+ throw new BadDataException(
250
+ "This exception is marked as resolved. Unresolve it before starting an AI agent task for it.",
251
+ );
252
+ }
253
+
254
+ if (telemetryException.isArchived) {
255
+ throw new BadDataException(
256
+ "This exception is archived. Unarchive it before starting an AI agent task for it.",
257
+ );
258
+ }
259
+
260
+ /*
261
+ * Human "closed without merging" feedback: when an AI FIX PR for this
262
+ * exception was declined, the automatic lane must not keep re-opening
263
+ * fix PRs for it. Scoped to the FixException recipe — the stamp is only
264
+ * ever SET for closed fix PRs (SyncPullRequestStates), so a regression
265
+ * test or error-handling task is neither blocked by it nor allowed to
266
+ * clear it. A HUMAN clicking "Fix with AI" is the explicit override;
267
+ * the stamp is cleared only AFTER the retry run is actually created
268
+ * (see below), so a retry that fails a later gate leaves the human's
269
+ * decline in force.
270
+ */
271
+ const isDeclinedFixOverride: boolean = Boolean(
272
+ telemetryException.aiFixDeclinedAt &&
273
+ taskType === CodeFixTaskType.FixException,
274
+ );
275
+
276
+ if (isDeclinedFixOverride && !props.userId) {
277
+ throw new BadDataException(
278
+ "A previous AI fix pull request for this exception was closed without merging, so automatic fix attempts are paused for it. A user can retry from the exception page.",
279
+ );
280
+ }
281
+
232
282
  /*
233
283
  * G11 guardrail: the per-project daily fix-run budget. Checked before
234
284
  * the (more expensive) readiness probes — an over-budget project gets
@@ -261,38 +311,107 @@ export class Service extends DatabaseService<Model> {
261
311
  }
262
312
 
263
313
  /*
264
- * Duplicate guard is per (exception, taskType): an active FixException
265
- * run must not block queuing a WriteRegressionTest run and vice versa.
314
+ * The duplicate guards below are check-then-create: without
315
+ * serialization, two triage completions for splintered variants of the
316
+ * same root cause can both pass the guards and both create runs. A
317
+ * per-project Redis mutex closes the window; acquisition failure
318
+ * (Redis down) degrades to the unserialized behavior rather than
319
+ * blocking fix creation — the guards still run, they just race.
266
320
  */
267
- await this.validateNoActiveCodeFixRunExists(telemetryExceptionId, taskType);
268
-
269
- const run: AIRun = new AIRun();
270
- run.projectId = telemetryException.projectId;
271
- run.runType = AIRunType.CodeFix;
272
- run.codeFixTaskType = taskType;
273
- run.status = AIRunStatus.Queued;
274
- run.triggeredByTelemetryExceptionId = telemetryExceptionId;
275
-
276
- // Attribution: the user who clicked "Fix with AI Agent".
277
- if (props.userId) {
278
- run.userId = props.userId;
321
+ let mutex: SemaphoreMutex | null = null;
322
+
323
+ try {
324
+ mutex = await Semaphore.lock({
325
+ key: telemetryException.projectId.toString(),
326
+ namespace: "TelemetryExceptionService.createCodeFixRunForException",
327
+ lockTimeout: 15000,
328
+ });
329
+ } catch (err) {
330
+ logger.debug(
331
+ `Could not acquire the code-fix creation mutex for project ${telemetryException.projectId.toString()} — proceeding without serialization: ${err}`,
332
+ );
279
333
  }
280
334
 
281
- /*
282
- * Created as root: AIRun rows are server-written only (empty create
283
- * ACL); the user's access was already checked by the exception read.
284
- */
285
- const createdRun: AIRun = await AIRunService.create({
286
- data: run,
287
- props: {
288
- isRoot: true,
289
- },
290
- });
335
+ let createdRun: AIRun;
336
+
337
+ try {
338
+ /*
339
+ * Duplicate guard is per (exception, taskType): an active
340
+ * FixException run must not block queuing a WriteRegressionTest run
341
+ * and vice versa.
342
+ */
343
+ await this.validateNoActiveCodeFixRunExists(
344
+ telemetryExceptionId,
345
+ taskType,
346
+ );
347
+
348
+ /*
349
+ * Cross-group duplicate guard: interpolated dynamic values (garbage
350
+ * UUIDs, file paths, request payloads) splinter one root cause into
351
+ * many fingerprints, and the per-exception guard above cannot see
352
+ * that. Compare NORMALIZED messages against exceptions that already
353
+ * have an active run or an open AI pull request, so one root cause
354
+ * yields one PR — not one per interpolated variant.
355
+ */
356
+ await this.validateNoOpenFixForSimilarException({
357
+ projectId: telemetryException.projectId,
358
+ telemetryExceptionId: telemetryExceptionId,
359
+ taskType: taskType,
360
+ message: telemetryException.message || "",
361
+ exceptionType: telemetryException.exceptionType || "",
362
+ });
363
+
364
+ const run: AIRun = new AIRun();
365
+ run.projectId = telemetryException.projectId;
366
+ run.runType = AIRunType.CodeFix;
367
+ run.codeFixTaskType = taskType;
368
+ run.status = AIRunStatus.Queued;
369
+ run.triggeredByTelemetryExceptionId = telemetryExceptionId;
370
+
371
+ // Attribution: the user who clicked "Fix with AI Agent".
372
+ if (props.userId) {
373
+ run.userId = props.userId;
374
+ }
375
+
376
+ /*
377
+ * Created as root: AIRun rows are server-written only (empty create
378
+ * ACL); the user's access was already checked by the exception read.
379
+ */
380
+ createdRun = await AIRunService.create({
381
+ data: run,
382
+ props: {
383
+ isRoot: true,
384
+ },
385
+ });
386
+ } finally {
387
+ if (mutex) {
388
+ try {
389
+ await Semaphore.release(mutex);
390
+ } catch (err) {
391
+ logger.debug(`Failed to release the code-fix creation mutex: ${err}`);
392
+ }
393
+ }
394
+ }
291
395
 
292
396
  if (!createdRun.id) {
293
397
  throw new BadDataException("Failed to create the AI fix run");
294
398
  }
295
399
 
400
+ /*
401
+ * The human's retry actually went through — only now consume the
402
+ * decline override. A retry that failed any gate above leaves the
403
+ * suppression in force.
404
+ */
405
+ if (isDeclinedFixOverride) {
406
+ await this.updateOneById({
407
+ id: telemetryExceptionId,
408
+ data: {
409
+ aiFixDeclinedAt: null,
410
+ },
411
+ props: { isRoot: true },
412
+ });
413
+ }
414
+
296
415
  return createdRun;
297
416
  }
298
417
 
@@ -338,6 +457,183 @@ export class Service extends DatabaseService<Model> {
338
457
  }
339
458
  }
340
459
 
460
+ /*
461
+ * Cross-group duplicate guard. Two exception groups are "the same work"
462
+ * for the fix lane when their exceptionType and NORMALIZED message match
463
+ * (normalizeExceptionText replaces UUIDs, IDs, emails, paths' dynamic
464
+ * segments, timestamps...). Candidates are the exceptions behind
465
+ * (a) non-terminal CodeFix runs of the same recipe, and (b) OPEN
466
+ * AI-authored pull requests — a completed run whose PR is still open
467
+ * must keep blocking, or every interpolated variant re-fixes the same
468
+ * root cause (observed: 15 near-identical PRs for one invalid-uuid
469
+ * throw site).
470
+ */
471
+ @CaptureSpan()
472
+ private async validateNoOpenFixForSimilarException(data: {
473
+ projectId: ObjectID;
474
+ telemetryExceptionId: ObjectID;
475
+ taskType: CodeFixTaskType;
476
+ message: string;
477
+ exceptionType: string;
478
+ }): Promise<void> {
479
+ // (a) Exceptions with a non-terminal run of the same recipe.
480
+ const activeRuns: Array<AIRun> = await AIRunService.findBy({
481
+ query: {
482
+ projectId: data.projectId,
483
+ runType: AIRunType.CodeFix,
484
+ /*
485
+ * A null codeFixTaskType means FixException (rows created before
486
+ * task recipes existed) — same legacy-null matching as
487
+ * validateNoActiveCodeFixRunExists.
488
+ */
489
+ codeFixTaskType:
490
+ data.taskType === CodeFixTaskType.FixException
491
+ ? QueryHelper.equalToOrNull(CodeFixTaskType.FixException)
492
+ : data.taskType,
493
+ status: QueryHelper.notIn([
494
+ AIRunStatus.Completed,
495
+ AIRunStatus.NoFixFound,
496
+ AIRunStatus.Error,
497
+ AIRunStatus.Cancelled,
498
+ AIRunStatus.Stale,
499
+ ]),
500
+ },
501
+ select: {
502
+ triggeredByTelemetryExceptionId: true,
503
+ },
504
+ limit: LIMIT_MAX,
505
+ skip: 0,
506
+ props: { isRoot: true },
507
+ });
508
+
509
+ // (b) Exceptions behind still-open AI pull requests.
510
+ const openPullRequests: Array<AIAgentTaskPullRequest> =
511
+ await AIAgentTaskPullRequestService.findBy({
512
+ query: {
513
+ projectId: data.projectId,
514
+ pullRequestState: PullRequestState.Open,
515
+ },
516
+ select: {
517
+ aiRunId: true,
518
+ },
519
+ limit: LIMIT_MAX,
520
+ skip: 0,
521
+ props: { isRoot: true },
522
+ });
523
+
524
+ /*
525
+ * A guard that silently stops guarding is worse than none — surface
526
+ * candidate-scan truncation instead of quietly missing duplicates.
527
+ */
528
+ if (
529
+ activeRuns.length >= LIMIT_MAX ||
530
+ openPullRequests.length >= LIMIT_MAX
531
+ ) {
532
+ logger.warn(
533
+ `Cross-group AI fix dedupe guard: candidate scan truncated at ${LIMIT_MAX} rows for project ${data.projectId.toString()} — the duplicate-PR guard may miss matches.`,
534
+ );
535
+ }
536
+
537
+ const openPrRunIds: Array<ObjectID> = openPullRequests
538
+ .map((pullRequest: AIAgentTaskPullRequest) => {
539
+ return pullRequest.aiRunId;
540
+ })
541
+ .filter((runId: ObjectID | undefined): runId is ObjectID => {
542
+ return Boolean(runId);
543
+ });
544
+
545
+ const openPrRuns: Array<AIRun> =
546
+ openPrRunIds.length > 0
547
+ ? await AIRunService.findBy({
548
+ query: {
549
+ _id: new Includes(openPrRunIds),
550
+ projectId: data.projectId,
551
+ runType: AIRunType.CodeFix,
552
+ codeFixTaskType:
553
+ data.taskType === CodeFixTaskType.FixException
554
+ ? QueryHelper.equalToOrNull(CodeFixTaskType.FixException)
555
+ : data.taskType,
556
+ },
557
+ select: {
558
+ triggeredByTelemetryExceptionId: true,
559
+ },
560
+ limit: LIMIT_MAX,
561
+ skip: 0,
562
+ props: { isRoot: true },
563
+ })
564
+ : [];
565
+
566
+ /*
567
+ * The target exception is deliberately NOT excluded: its own COMPLETED
568
+ * run with a still-open PR must keep blocking a re-fix (the
569
+ * non-terminal guard above cannot see completed runs). Its own ACTIVE
570
+ * runs were already rejected by validateNoActiveCodeFixRunExists.
571
+ */
572
+ const candidateExceptionIds: Array<ObjectID> = [];
573
+ const seenIds: Set<string> = new Set<string>();
574
+
575
+ for (const run of [...activeRuns, ...openPrRuns]) {
576
+ const exceptionId: ObjectID | undefined =
577
+ run.triggeredByTelemetryExceptionId;
578
+
579
+ if (!exceptionId) {
580
+ continue;
581
+ }
582
+
583
+ const idString: string = exceptionId.toString();
584
+
585
+ if (seenIds.has(idString)) {
586
+ continue;
587
+ }
588
+
589
+ seenIds.add(idString);
590
+ candidateExceptionIds.push(exceptionId);
591
+ }
592
+
593
+ if (candidateExceptionIds.length === 0) {
594
+ return;
595
+ }
596
+
597
+ const targetSignature: string = `${data.exceptionType}|${normalizeExceptionText(
598
+ data.message,
599
+ )}`;
600
+
601
+ const candidates: Array<Model> = await this.findBy({
602
+ query: {
603
+ projectId: data.projectId,
604
+ _id: new Includes(candidateExceptionIds),
605
+ },
606
+ select: {
607
+ _id: true,
608
+ message: true,
609
+ exceptionType: true,
610
+ },
611
+ limit: candidateExceptionIds.length,
612
+ skip: 0,
613
+ props: { isRoot: true },
614
+ });
615
+
616
+ for (const candidate of candidates) {
617
+ const candidateSignature: string = `${candidate.exceptionType || ""}|${normalizeExceptionText(
618
+ candidate.message || "",
619
+ )}`;
620
+
621
+ if (candidateSignature !== targetSignature) {
622
+ continue;
623
+ }
624
+
625
+ if (candidate._id?.toString() === data.telemetryExceptionId.toString()) {
626
+ throw new BadDataException(
627
+ "This exception already has an open AI pull request for this task type. Review that pull request instead of opening another one.",
628
+ );
629
+ }
630
+
631
+ throw new BadDataException(
632
+ `A similar exception (same type and normalized message, exception ${candidate._id?.toString()}) already has an AI agent task in progress or an open AI pull request. Review that pull request instead of opening another one for the same root cause.`,
633
+ );
634
+ }
635
+ }
636
+
341
637
  @CaptureSpan()
342
638
  public async getDashboardSummary(
343
639
  props: DatabaseCommonInteractionProps,
@@ -49,6 +49,40 @@ export class Service extends DatabaseService<Model> {
49
49
  createdItem.webhookSecretKey = secretKey;
50
50
  }
51
51
 
52
+ /*
53
+ * A workflow that arrives with its graph already in place - imported from
54
+ * a JSON export, or duplicated from another workflow - never passes
55
+ * through onUpdateSuccess, so nothing has denormalized its trigger onto
56
+ * the row. The runner looks workflows up by triggerId, so without this the
57
+ * workflow is saved and looks correct in the builder but silently never
58
+ * fires.
59
+ */
60
+ if (createdItem.id && createdItem.graph) {
61
+ await this.saveTriggerFromGraph({
62
+ workflowId: createdItem.id,
63
+ graph: createdItem.graph,
64
+ });
65
+
66
+ /*
67
+ * Registers schedule triggers with the runner. Best effort: the row and
68
+ * its trigger are already persisted, so a workflow service outage must
69
+ * not fail the create. The trigger is already on the row, so the next
70
+ * save - or the runner's own startup scan in Schedule.init, which
71
+ * queries by triggerId - picks the workflow up.
72
+ */
73
+ try {
74
+ await this.notifyWorkflowService(createdItem.id);
75
+ } catch (error) {
76
+ logger.error(
77
+ `Error notifying workflow service of created workflow: ${error}`,
78
+ {
79
+ projectId: createdItem.projectId?.toString(),
80
+ workflowId: createdItem.id?.toString(),
81
+ } as LogAttributes,
82
+ );
83
+ }
84
+ }
85
+
52
86
  if (createdItem.projectId && createdItem.id) {
53
87
  /*
54
88
  * Run label rule first so rule-added labels are persisted before
@@ -87,39 +121,13 @@ export class Service extends DatabaseService<Model> {
87
121
  ): Promise<OnUpdate<Model>> {
88
122
  /// save trigger and trigger args.
89
123
 
90
- if (
91
- onUpdate.updateBy.data &&
92
- (onUpdate.updateBy.data as any).graph &&
93
- (((onUpdate.updateBy.data as any).graph as any)[
94
- "nodes"
95
- ] as Array<JSONObject>)
96
- ) {
97
- let trigger: NodeDataProp | null = null;
98
-
99
- // check if it has a trigger node.
100
- for (const node of ((onUpdate.updateBy.data as any).graph as any)[
101
- "nodes"
102
- ] as Array<JSONObject>) {
103
- const nodeData: NodeDataProp = node["data"] as any;
104
- if (
105
- nodeData.componentType === ComponentType.Trigger &&
106
- nodeData.nodeType === NodeType.Node
107
- ) {
108
- // found the trigger;
109
- trigger = nodeData;
110
- }
111
- }
124
+ const updatedGraph: JSONObject | undefined = (onUpdate.updateBy.data as any)
125
+ ?.graph as JSONObject | undefined;
112
126
 
113
- await this.updateOneById({
114
- id: new ObjectID(onUpdate.updateBy.query._id! as any),
115
- data: {
116
- triggerId: trigger?.metadataId || null,
117
- triggerArguments: trigger?.arguments || {},
118
- } as any,
119
- props: {
120
- isRoot: true,
121
- ignoreHooks: true,
122
- },
127
+ if (updatedGraph) {
128
+ await this.saveTriggerFromGraph({
129
+ workflowId: new ObjectID(onUpdate.updateBy.query._id! as any),
130
+ graph: updatedGraph,
123
131
  });
124
132
  }
125
133
 
@@ -127,23 +135,76 @@ export class Service extends DatabaseService<Model> {
127
135
  workflowId: onUpdate.updateBy.query._id?.toString(),
128
136
  } as LogAttributes);
129
137
 
138
+ await this.notifyWorkflowService(
139
+ new ObjectID(onUpdate.updateBy.query._id! as any),
140
+ );
141
+
142
+ logger.debug("Updated workflow on the workflow service", {
143
+ workflowId: onUpdate.updateBy.query._id?.toString(),
144
+ } as LogAttributes);
145
+
146
+ return onUpdate;
147
+ }
148
+
149
+ /*
150
+ * The trigger node is denormalized out of the graph onto triggerId /
151
+ * triggerArguments because the runner queries workflows by trigger and
152
+ * cannot parse every graph to do it. A graph with no trigger node clears
153
+ * both columns, which is how a workflow stops firing when its trigger is
154
+ * removed in the builder.
155
+ */
156
+ private async saveTriggerFromGraph(data: {
157
+ workflowId: ObjectID;
158
+ graph: JSONObject;
159
+ }): Promise<void> {
160
+ const nodes: Array<JSONObject> | undefined = data.graph["nodes"] as
161
+ | Array<JSONObject>
162
+ | undefined;
163
+
164
+ if (!nodes || !Array.isArray(nodes)) {
165
+ return;
166
+ }
167
+
168
+ let trigger: NodeDataProp | null = null;
169
+
170
+ // check if it has a trigger node.
171
+ for (const node of nodes) {
172
+ const nodeData: NodeDataProp = node["data"] as any;
173
+
174
+ if (
175
+ nodeData?.componentType === ComponentType.Trigger &&
176
+ nodeData?.nodeType === NodeType.Node
177
+ ) {
178
+ // found the trigger;
179
+ trigger = nodeData;
180
+ }
181
+ }
182
+
183
+ await this.updateOneById({
184
+ id: data.workflowId,
185
+ data: {
186
+ triggerId: trigger?.metadataId || null,
187
+ triggerArguments: trigger?.arguments || {},
188
+ } as any,
189
+ props: {
190
+ isRoot: true,
191
+ ignoreHooks: true,
192
+ },
193
+ });
194
+ }
195
+
196
+ private async notifyWorkflowService(workflowId: ObjectID): Promise<void> {
130
197
  await API.post<EmptyResponseData>({
131
198
  url: new URL(
132
199
  Protocol.HTTP,
133
200
  WorkflowHostname,
134
- new Route("/workflow/update/" + onUpdate.updateBy.query._id!),
201
+ new Route("/workflow/update/" + workflowId.toString()),
135
202
  ),
136
203
  data: {},
137
204
  headers: {
138
205
  ...ClusterKeyAuthorization.getClusterKeyHeaders(),
139
206
  },
140
207
  });
141
-
142
- logger.debug("Updated workflow on the workflow service", {
143
- workflowId: onUpdate.updateBy.query._id?.toString(),
144
- } as LogAttributes);
145
-
146
- return onUpdate;
147
208
  }
148
209
  }
149
210
  export default new Service();