@oneuptime/common 11.5.12 → 11.5.13

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 (75) hide show
  1. package/Models/DatabaseModels/AIInsight.ts +30 -0
  2. package/Models/DatabaseModels/Project.ts +30 -0
  3. package/Models/DatabaseModels/TelemetryException.ts +128 -0
  4. package/Server/API/AIAgentDataAPI.ts +129 -5
  5. package/Server/API/AIInvestigationAPI.ts +87 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.ts +56 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  8. package/Server/Services/AIRunService.ts +8 -0
  9. package/Server/Services/TelemetryExceptionService.ts +320 -24
  10. package/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.ts +15 -4
  11. package/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.ts +15 -4
  12. package/Server/Utils/AI/SRE/Insights/InsightScanner.ts +222 -24
  13. package/Server/Utils/AI/SRE/Insights/InsightTriageRunner.ts +225 -9
  14. package/Server/Utils/AI/SRE/SubjectCodeFixRun.ts +39 -0
  15. package/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.ts +114 -0
  16. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +222 -0
  17. package/Tests/Server/Services/TelemetryExceptionCodeFixRun.test.ts +205 -1
  18. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +150 -141
  19. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +277 -1
  20. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +70 -0
  21. package/Tests/Server/Utils/InsightTriageClassification.test.ts +57 -0
  22. package/Tests/Server/Utils/TelemetryImprovementTaskTrigger.test.ts +141 -0
  23. package/Tests/UI/Components/ModelTableSelectFromColumns.test.ts +259 -0
  24. package/Types/AI/CodeFixTaskContext.ts +6 -0
  25. package/Types/AI/CodeFixTaskType.ts +38 -1
  26. package/Types/AI/ExceptionAIClassification.ts +27 -0
  27. package/Types/Log/LogScrubPatternType.ts +7 -0
  28. package/Types/Trace/TraceScrubPatternType.ts +7 -0
  29. package/UI/Components/ModelTable/BaseModelTable.tsx +15 -46
  30. package/UI/Components/ModelTable/SelectFromColumns.ts +129 -0
  31. package/build/dist/Models/DatabaseModels/AIInsight.js +31 -0
  32. package/build/dist/Models/DatabaseModels/AIInsight.js.map +1 -1
  33. package/build/dist/Models/DatabaseModels/Project.js +31 -0
  34. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  35. package/build/dist/Models/DatabaseModels/TelemetryException.js +134 -0
  36. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  37. package/build/dist/Server/API/AIAgentDataAPI.js +97 -8
  38. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  39. package/build/dist/Server/API/AIInvestigationAPI.js +55 -0
  40. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -1
  41. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js +33 -0
  42. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784640000000-AddExceptionTriageAndPrivacyColumns.js.map +1 -0
  43. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  44. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  45. package/build/dist/Server/Services/AIRunService.js +9 -2
  46. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  47. package/build/dist/Server/Services/TelemetryExceptionService.js +263 -22
  48. package/build/dist/Server/Services/TelemetryExceptionService.js.map +1 -1
  49. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js +12 -4
  50. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/ExceptionSpikeDetector.js.map +1 -1
  51. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js +12 -4
  52. package/build/dist/Server/Utils/AI/SRE/Insights/Detectors/NewExceptionDetector.js.map +1 -1
  53. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js +193 -22
  54. package/build/dist/Server/Utils/AI/SRE/Insights/InsightScanner.js.map +1 -1
  55. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js +178 -9
  56. package/build/dist/Server/Utils/AI/SRE/Insights/InsightTriageRunner.js.map +1 -1
  57. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js +37 -0
  58. package/build/dist/Server/Utils/AI/SRE/SubjectCodeFixRun.js.map +1 -1
  59. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js +98 -0
  60. package/build/dist/Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger.js.map +1 -0
  61. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +149 -0
  62. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -0
  63. package/build/dist/Types/AI/CodeFixTaskType.js +38 -1
  64. package/build/dist/Types/AI/CodeFixTaskType.js.map +1 -1
  65. package/build/dist/Types/AI/ExceptionAIClassification.js +28 -0
  66. package/build/dist/Types/AI/ExceptionAIClassification.js.map +1 -0
  67. package/build/dist/Types/Log/LogScrubPatternType.js +7 -0
  68. package/build/dist/Types/Log/LogScrubPatternType.js.map +1 -1
  69. package/build/dist/Types/Trace/TraceScrubPatternType.js +7 -0
  70. package/build/dist/Types/Trace/TraceScrubPatternType.js.map +1 -1
  71. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +12 -37
  72. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  73. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js +82 -0
  74. package/build/dist/UI/Components/ModelTable/SelectFromColumns.js.map +1 -0
  75. package/package.json +1 -1
@@ -0,0 +1,114 @@
1
+ import ObjectID from "../../../../Types/ObjectID";
2
+ import CodeFixTaskType from "../../../../Types/AI/CodeFixTaskType";
3
+ import CodeFixTaskContext from "../../../../Types/AI/CodeFixTaskContext";
4
+ import BadDataException from "../../../../Types/Exception/BadDataException";
5
+ import AIRun from "../../../../Models/DatabaseModels/AIRun";
6
+ import Service from "../../../../Models/DatabaseModels/Service";
7
+ import ServiceService from "../../../Services/ServiceService";
8
+ import SubjectCodeFixRun from "./SubjectCodeFixRun";
9
+ import CaptureSpan from "../../Telemetry/CaptureSpan";
10
+
11
+ /*
12
+ * AI SRE — the service-scoped instrumentation-improvement triggers:
13
+ * ImproveLogging and ImproveTracing. One click on a service's Logs or
14
+ * Traces page opens a draft PR that improves that service's logging or
15
+ * tracing hygiene — parameterized log messages, correct severities, spans
16
+ * on uninstrumented paths, proper exception recording — WITHOUT changing
17
+ * behavior.
18
+ *
19
+ * Like FixFromIncident and FixPerformance these are human-triggered from a
20
+ * user-facing endpoint (POST /ai-investigation/
21
+ * create-telemetry-improvement-task), so there is no project opt-in flag —
22
+ * the human in the loop IS the gate — and every unmet gate FAILS EARLY
23
+ * with a clear message. The recipe's grounding is the SERVICE: repository
24
+ * resolution at task-details time uses the service name (name-match /
25
+ * only-repository), the same path the incident-subject recipes use.
26
+ */
27
+ export default class TelemetryImprovementTaskTrigger {
28
+ /*
29
+ * Gate and enqueue an ImproveLogging / ImproveTracing CodeFix run for a
30
+ * telemetry service. The caller must already have loaded the service
31
+ * under the USER's permissions (the access check) — this method reads
32
+ * and writes as root.
33
+ *
34
+ * Throws BadDataException naming the failed gate: unsupported recipe, no
35
+ * GitHub-App repository, a duplicate active run for the same service, or
36
+ * (via enqueueSubjectCodeFixRun) the daily fix-run budget.
37
+ */
38
+ @CaptureSpan()
39
+ public static async createTelemetryImprovementTask(data: {
40
+ projectId: ObjectID;
41
+ telemetryServiceId: ObjectID;
42
+ taskType: CodeFixTaskType;
43
+ userId: ObjectID;
44
+ }): Promise<AIRun> {
45
+ if (
46
+ data.taskType !== CodeFixTaskType.ImproveLogging &&
47
+ data.taskType !== CodeFixTaskType.ImproveTracing
48
+ ) {
49
+ throw new BadDataException(
50
+ `Task type "${data.taskType}" is not a telemetry-improvement recipe. Supported: ${CodeFixTaskType.ImproveLogging}, ${CodeFixTaskType.ImproveTracing}.`,
51
+ );
52
+ }
53
+
54
+ const service: Service | null = await ServiceService.findOneById({
55
+ id: data.telemetryServiceId,
56
+ select: {
57
+ _id: true,
58
+ name: true,
59
+ projectId: true,
60
+ },
61
+ props: { isRoot: true },
62
+ });
63
+
64
+ if (
65
+ !service ||
66
+ service.projectId?.toString() !== data.projectId.toString()
67
+ ) {
68
+ throw new BadDataException("Telemetry service not found.");
69
+ }
70
+
71
+ // Gate — a repository the agent can actually open a PR against.
72
+ const hasConnectedRepository: boolean =
73
+ await SubjectCodeFixRun.hasGitHubAppConnectedRepository(data.projectId);
74
+
75
+ if (!hasConnectedRepository) {
76
+ throw new BadDataException(
77
+ "No GitHub-App-connected repository exists for this project, so the agent has nowhere to open the pull request. Connect one under AI > Code Repositories.",
78
+ );
79
+ }
80
+
81
+ /*
82
+ * Gate — per-(service, recipe) dedupe: repeated clicks must not fan
83
+ * out into duplicate PRs.
84
+ */
85
+ const existingRun: AIRun | null =
86
+ await SubjectCodeFixRun.findNonTerminalRunForTelemetryService({
87
+ projectId: data.projectId,
88
+ taskType: data.taskType,
89
+ telemetryServiceId: data.telemetryServiceId.toString(),
90
+ });
91
+
92
+ if (existingRun) {
93
+ throw new BadDataException(
94
+ `An ${
95
+ data.taskType === CodeFixTaskType.ImproveLogging
96
+ ? "improve-logging"
97
+ : "improve-tracing"
98
+ } task is already queued or running for this service. Track its progress on the AI > Tasks page.`,
99
+ );
100
+ }
101
+
102
+ const taskContext: CodeFixTaskContext = {
103
+ telemetryServiceId: data.telemetryServiceId.toString(),
104
+ serviceName: service.name || undefined,
105
+ };
106
+
107
+ return SubjectCodeFixRun.enqueueSubjectCodeFixRun({
108
+ projectId: data.projectId,
109
+ taskType: data.taskType,
110
+ userId: data.userId,
111
+ taskContext,
112
+ });
113
+ }
114
+ }
@@ -0,0 +1,222 @@
1
+ import ToolResultSerializer from "../AI/Toolbox/Serializer";
2
+
3
+ /*
4
+ * Shared exception-text sanitization.
5
+ *
6
+ * normalizeExceptionText is the fingerprint normalizer that used to live
7
+ * in App/FeatureSet/Telemetry/Utils/Exception.ts — it was moved here (the
8
+ * App util now delegates) so server-side consumers in Common (the AI
9
+ * agent data API) can sanitize exception messages before they reach LLM
10
+ * prompts, pull-request text, and commit messages. The replacement
11
+ * behavior MUST stay byte-for-byte stable: fingerprints are computed from
12
+ * its output, and changing it regroups every existing exception.
13
+ */
14
+
15
+ /**
16
+ * Normalizes a string by replacing dynamic values with placeholders.
17
+ * This ensures that exceptions with the same root cause but different
18
+ * dynamic values (like IDs, timestamps, etc.) get the same fingerprint.
19
+ *
20
+ * @param text - The text to normalize (message or stack trace)
21
+ * @returns The normalized text with dynamic values replaced
22
+ */
23
+ export function normalizeExceptionText(text: string): string {
24
+ if (!text) {
25
+ return "";
26
+ }
27
+
28
+ let normalized: string = text;
29
+
30
+ // Order matters! More specific patterns should come before generic ones.
31
+
32
+ // 1. UUIDs (e.g., 550e8400-e29b-41d4-a716-446655440000)
33
+ normalized = normalized.replace(
34
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
35
+ "<UUID>",
36
+ );
37
+
38
+ // 2. MongoDB ObjectIDs (24 hex characters)
39
+ normalized = normalized.replace(/\b[0-9a-f]{24}\b/gi, "<OBJECT_ID>");
40
+
41
+ /*
42
+ * 3. Stripe-style IDs (e.g., sub_xxx, cus_xxx, pi_xxx, ch_xxx, etc.)
43
+ * These have a prefix followed by underscore and alphanumeric characters
44
+ */
45
+ normalized = normalized.replace(
46
+ /\b(sub|cus|pi|ch|pm|card|price|prod|inv|txn|evt|req|acct|payout|ba|btok|src|tok|seti|si|cs|link|file|dp|icr|ii|il|is|isci|mbur|or|po|qt|rcpt|re|refund|sku|tax|txi|tr|us|wh)_[A-Za-z0-9]{10,32}\b/g,
47
+ "<STRIPE_ID>",
48
+ );
49
+
50
+ /*
51
+ * 4. Generic API/Service IDs - alphanumeric strings that look like IDs
52
+ * Matches patterns like: prefix_alphanumeric or just long alphanumeric strings
53
+ * Common in many services (AWS, GCP, etc.)
54
+ */
55
+ normalized = normalized.replace(
56
+ /\b[a-z]{2,10}_[A-Za-z0-9]{8,}\b/g,
57
+ "<SERVICE_ID>",
58
+ );
59
+
60
+ // 5. JWT tokens (three base64 segments separated by dots)
61
+ normalized = normalized.replace(
62
+ /\beyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\b/g,
63
+ "<JWT>",
64
+ );
65
+
66
+ // 6. Base64 encoded strings (long sequences, likely tokens or encoded data)
67
+ normalized = normalized.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "<BASE64>");
68
+
69
+ // 7. IP addresses (IPv4)
70
+ normalized = normalized.replace(
71
+ /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
72
+ "<IP>",
73
+ );
74
+
75
+ // 8. IP addresses (IPv6) - simplified pattern
76
+ normalized = normalized.replace(
77
+ /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/g,
78
+ "<IPV6>",
79
+ );
80
+ normalized = normalized.replace(/\b::1\b/g, "<IPV6>"); // localhost IPv6
81
+
82
+ // 9. Email addresses
83
+ normalized = normalized.replace(
84
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
85
+ "<EMAIL>",
86
+ );
87
+
88
+ /*
89
+ * 10. URLs with dynamic paths/query params (normalize the dynamic parts)
90
+ * Keep the domain but normalize path segments that look like IDs
91
+ */
92
+ normalized = normalized.replace(
93
+ /\/[0-9a-f]{8,}(?=\/|$|\?|#|\s|'|")/gi,
94
+ "/<ID>",
95
+ );
96
+
97
+ /*
98
+ * 11. Timestamps in various formats
99
+ * ISO 8601 timestamps
100
+ */
101
+ normalized = normalized.replace(
102
+ /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g,
103
+ "<TIMESTAMP>",
104
+ );
105
+ // Unix timestamps (10 or 13 digits)
106
+ normalized = normalized.replace(/\b1[0-9]{9,12}\b/g, "<TIMESTAMP>");
107
+
108
+ // 12. Date formats (YYYY-MM-DD, MM/DD/YYYY, etc.)
109
+ normalized = normalized.replace(/\b\d{4}[-/]\d{2}[-/]\d{2}\b/g, "<DATE>");
110
+ normalized = normalized.replace(/\b\d{2}[-/]\d{2}[-/]\d{4}\b/g, "<DATE>");
111
+
112
+ // 13. Time formats (HH:MM:SS, HH:MM)
113
+ normalized = normalized.replace(/\b\d{2}:\d{2}(?::\d{2})?\b/g, "<TIME>");
114
+
115
+ // 14. Memory addresses (0x followed by hex)
116
+ normalized = normalized.replace(/\b0x[0-9a-fA-F]+\b/g, "<MEMORY_ADDR>");
117
+
118
+ // 15. Session IDs (common patterns) - MUST come before hex ID pattern
119
+ normalized = normalized.replace(
120
+ /\bsession[_-]?id[=:\s]*['"]?[A-Za-z0-9_-]+['"]?/gi,
121
+ "session_id=<SESSION>",
122
+ );
123
+
124
+ // 16. Request IDs (common patterns) - MUST come before hex ID pattern
125
+ normalized = normalized.replace(
126
+ /\brequest[_-]?id[=:\s]*['"]?[A-Za-z0-9_-]+['"]?/gi,
127
+ "request_id=<REQUEST>",
128
+ );
129
+
130
+ // 17. Correlation IDs - MUST come before hex ID pattern
131
+ normalized = normalized.replace(
132
+ /\bcorrelation[_-]?id[=:\s]*['"]?[A-Za-z0-9_-]+['"]?/gi,
133
+ "correlation_id=<CORRELATION>",
134
+ );
135
+
136
+ // 18. Transaction IDs - MUST come before hex ID pattern
137
+ normalized = normalized.replace(
138
+ /\btransaction[_-]?id[=:\s]*['"]?[A-Za-z0-9_-]+['"]?/gi,
139
+ "transaction_id=<TRANSACTION>",
140
+ );
141
+
142
+ // 19. Hex strings that are likely IDs (8+ chars)
143
+ normalized = normalized.replace(/\b[0-9a-f]{8,}\b/gi, "<HEX_ID>");
144
+
145
+ /*
146
+ * 20. Quoted strings containing IDs or dynamic values
147
+ * Match strings in single or double quotes that look like IDs
148
+ */
149
+ normalized = normalized.replace(/'[A-Za-z0-9_-]{16,}'/g, "'<ID>'");
150
+ normalized = normalized.replace(/"[A-Za-z0-9_-]{16,}"/g, '"<ID>"');
151
+
152
+ // 21. Port numbers in URLs or connection strings
153
+ normalized = normalized.replace(/:(\d{4,5})(?=\/|$|\s)/g, ":<PORT>");
154
+
155
+ /*
156
+ * 22. Line numbers in stack traces (keep for context, but normalize large numbers)
157
+ * This normalizes specific line/column references that might vary
158
+ */
159
+ normalized = normalized.replace(/:\d+:\d+\)?$/gm, ":<LINE>:<COL>)");
160
+
161
+ // 23. Process/Thread IDs
162
+ normalized = normalized.replace(/\bPID[:\s]*\d+\b/gi, "PID:<PID>");
163
+ normalized = normalized.replace(/\bTID[:\s]*\d+\b/gi, "TID:<TID>");
164
+
165
+ // 24. Numeric IDs in common patterns (id=123, id: 123, etc.)
166
+ normalized = normalized.replace(/\bid[=:\s]*['"]?\d+['"]?/gi, "id=<ID>");
167
+
168
+ // 25. Large numbers that are likely IDs (more than 6 digits)
169
+ normalized = normalized.replace(/\b\d{7,}\b/g, "<NUMBER>");
170
+
171
+ return normalized;
172
+ }
173
+
174
+ /**
175
+ * Sanitize an exception message for surfaces that leave the platform:
176
+ * LLM prompts, pull-request titles/bodies, and commit messages.
177
+ *
178
+ * Normalization replaces the dynamic tokens (UUIDs, emails, IPs, IDs,
179
+ * timestamps...) that carry user data, then the secret redactor sweeps
180
+ * whatever is left (bearer tokens, API keys, cards...). The message
181
+ * keeps its structure — "invalid input syntax for type uuid: <HEX_ID>"
182
+ * is still perfectly actionable for a fix.
183
+ */
184
+ export function sanitizeExceptionMessage(message: string): string {
185
+ if (!message) {
186
+ return "";
187
+ }
188
+ return ToolResultSerializer.redact(normalizeExceptionText(message)).text;
189
+ }
190
+
191
+ /**
192
+ * Sanitize a stack trace for the same surfaces.
193
+ *
194
+ * Runtimes prefix the native stack string with the exception MESSAGE
195
+ * ("Error: <message>" in Node, "ValueError: <message>" in Python
196
+ * headers, "Caused by: ..." in Java) — so a redact-only pass would leak
197
+ * the very identifiers sanitizeExceptionMessage strips. Frame lines are
198
+ * indented (" at fn (file:42:7)", ' File "x.py", line 3', "\tat ...")
199
+ * and must keep exact file:line references for the code agent, so the
200
+ * split is: unindented header/message lines get full normalization,
201
+ * indented frame lines are left intact, and the secret redactor sweeps
202
+ * everything at the end.
203
+ */
204
+ // Frame lines are indented; header/message lines are not.
205
+ const INDENTED_FRAME_LINE_REGEX: RegExp = /^\s/;
206
+
207
+ export function sanitizeStackTrace(stackTrace: string): string {
208
+ if (!stackTrace) {
209
+ return "";
210
+ }
211
+
212
+ const normalized: string = stackTrace
213
+ .split("\n")
214
+ .map((line: string): string => {
215
+ return INDENTED_FRAME_LINE_REGEX.test(line)
216
+ ? line
217
+ : normalizeExceptionText(line);
218
+ })
219
+ .join("\n");
220
+
221
+ return ToolResultSerializer.redact(normalized).text;
222
+ }
@@ -6,6 +6,7 @@ import ServiceService from "../../../Server/Services/ServiceService";
6
6
  import CodeRepositoryService from "../../../Server/Services/CodeRepositoryService";
7
7
  import AIAgentService from "../../../Server/Services/AIAgentService";
8
8
  import AIRunService from "../../../Server/Services/AIRunService";
9
+ import AIAgentTaskPullRequestService from "../../../Server/Services/AIAgentTaskPullRequestService";
9
10
  import { RepoResolution } from "../../../Server/Utils/CodeRepository/StackTraceRepoResolver";
10
11
  import FindOneBy from "../../../Server/Types/Database/FindOneBy";
11
12
  import TelemetryException from "../../../Models/DatabaseModels/TelemetryException";
@@ -37,12 +38,19 @@ const projectId: ObjectID = ObjectID.generate();
37
38
  const exceptionId: ObjectID = ObjectID.generate();
38
39
  const serviceId: ObjectID = ObjectID.generate();
39
40
 
40
- function fakeException(): TelemetryException {
41
+ function fakeException(
42
+ overrides: Partial<TelemetryException> = {},
43
+ ): TelemetryException {
41
44
  return {
42
45
  id: exceptionId,
43
46
  projectId: projectId,
44
47
  primaryEntityId: serviceId,
48
+ message: "Failed to charge card for user 123",
49
+ exceptionType: "PaymentError",
45
50
  stackTrace: "at charge (/app/src/billing/charge.ts:12:5)",
51
+ isResolved: false,
52
+ isArchived: false,
53
+ ...overrides,
46
54
  } as unknown as TelemetryException;
47
55
  }
48
56
 
@@ -91,6 +99,14 @@ function mockReadinessOk(): void {
91
99
  limitInTokens: null,
92
100
  usedTokensToday: 0,
93
101
  });
102
+
103
+ /*
104
+ * Cross-group duplicate guard collaborators: no non-terminal runs and no
105
+ * open AI pull requests unless a test overrides these. findBy on the
106
+ * exception service itself is only reached when candidates exist.
107
+ */
108
+ jest.spyOn(AIRunService, "findBy").mockResolvedValue([]);
109
+ jest.spyOn(AIAgentTaskPullRequestService, "findBy").mockResolvedValue([]);
94
110
  }
95
111
 
96
112
  describe("TelemetryExceptionService.createCodeFixRunForException", () => {
@@ -283,6 +299,194 @@ describe("TelemetryExceptionService.createCodeFixRunForException", () => {
283
299
  }),
284
300
  );
285
301
  });
302
+
303
+ test("rejects resolved and archived exceptions server-side", async () => {
304
+ mockReadinessOk();
305
+ const create: jest.SpyInstance = jest.spyOn(AIRunService, "create");
306
+
307
+ jest
308
+ .spyOn(TelemetryExceptionService, "findOneById")
309
+ .mockResolvedValue(fakeException({ isResolved: true }));
310
+
311
+ await expect(
312
+ TelemetryExceptionService.createCodeFixRunForException({
313
+ telemetryExceptionId: exceptionId,
314
+ props: { isRoot: true },
315
+ }),
316
+ ).rejects.toThrow(/marked as resolved/);
317
+
318
+ jest
319
+ .spyOn(TelemetryExceptionService, "findOneById")
320
+ .mockResolvedValue(fakeException({ isArchived: true }));
321
+
322
+ await expect(
323
+ TelemetryExceptionService.createCodeFixRunForException({
324
+ telemetryExceptionId: exceptionId,
325
+ props: { isRoot: true },
326
+ }),
327
+ ).rejects.toThrow(/archived/);
328
+
329
+ expect(create).not.toHaveBeenCalled();
330
+ });
331
+
332
+ test("a declined AI fix (aiFixDeclinedAt) blocks the automatic lane but a user click clears the stamp and proceeds", async () => {
333
+ mockReadinessOk();
334
+
335
+ jest
336
+ .spyOn(TelemetryExceptionService, "findOneById")
337
+ .mockResolvedValue(fakeException({ aiFixDeclinedAt: new Date() }));
338
+ jest.spyOn(AIRunService, "findOneBy").mockResolvedValue(null);
339
+ const create: jest.SpyInstance = jest
340
+ .spyOn(AIRunService, "create")
341
+ .mockResolvedValue({ id: ObjectID.generate() } as unknown as AIRun);
342
+ const clearStamp: jest.SpyInstance = jest
343
+ .spyOn(TelemetryExceptionService, "updateOneById")
344
+ .mockResolvedValue(undefined as never);
345
+
346
+ // System-triggered (no userId): blocked.
347
+ await expect(
348
+ TelemetryExceptionService.createCodeFixRunForException({
349
+ telemetryExceptionId: exceptionId,
350
+ props: { isRoot: true },
351
+ }),
352
+ ).rejects.toThrow(/closed without merging/);
353
+ expect(create).not.toHaveBeenCalled();
354
+
355
+ // A human click: clears the stamp and creates the run.
356
+ await expect(
357
+ TelemetryExceptionService.createCodeFixRunForException({
358
+ telemetryExceptionId: exceptionId,
359
+ props: { isRoot: true, userId: ObjectID.generate() },
360
+ }),
361
+ ).resolves.toBeDefined();
362
+
363
+ expect(clearStamp).toHaveBeenCalledWith(
364
+ expect.objectContaining({
365
+ id: exceptionId,
366
+ data: expect.objectContaining({ aiFixDeclinedAt: null }),
367
+ }),
368
+ );
369
+ expect(create).toHaveBeenCalled();
370
+ });
371
+
372
+ test("the decline stamp is scoped to FixException: other recipes are neither blocked by it nor clear it", async () => {
373
+ mockReadinessOk();
374
+
375
+ jest
376
+ .spyOn(TelemetryExceptionService, "findOneById")
377
+ .mockResolvedValue(fakeException({ aiFixDeclinedAt: new Date() }));
378
+ jest.spyOn(AIRunService, "findOneBy").mockResolvedValue(null);
379
+ const create: jest.SpyInstance = jest
380
+ .spyOn(AIRunService, "create")
381
+ .mockResolvedValue({ id: ObjectID.generate() } as unknown as AIRun);
382
+ const exceptionUpdates: jest.SpyInstance = jest
383
+ .spyOn(TelemetryExceptionService, "updateOneById")
384
+ .mockResolvedValue(undefined as never);
385
+
386
+ /*
387
+ * A user asking for a regression TEST on a group whose FIX a human
388
+ * declined: the test run proceeds, and — critically — the fix-decline
389
+ * suppression stays in force (the user never overrode the fix).
390
+ */
391
+ await expect(
392
+ TelemetryExceptionService.createCodeFixRunForException({
393
+ telemetryExceptionId: exceptionId,
394
+ props: { isRoot: true, userId: ObjectID.generate() },
395
+ taskType: CodeFixTaskType.WriteRegressionTest,
396
+ }),
397
+ ).resolves.toBeDefined();
398
+
399
+ expect(create).toHaveBeenCalled();
400
+ expect(exceptionUpdates).not.toHaveBeenCalledWith(
401
+ expect.objectContaining({
402
+ data: expect.objectContaining({ aiFixDeclinedAt: null }),
403
+ }),
404
+ );
405
+ });
406
+
407
+ test("cross-group duplicate guard: a similar exception (same type + normalized message) with an active run blocks creation", async () => {
408
+ mockReadinessOk();
409
+
410
+ /*
411
+ * The observed flood shape: one throw site interpolating a different
412
+ * UUID per request splinters into one fingerprint (and one exception
413
+ * group) per value. Normalization collapses both messages to
414
+ * `...uuid: "<UUID>"`, which is what the guard compares.
415
+ */
416
+ jest.spyOn(TelemetryExceptionService, "findOneById").mockResolvedValue(
417
+ fakeException({
418
+ message:
419
+ 'invalid input syntax for type uuid: "550e8400-e29b-41d4-a716-446655440000"',
420
+ exceptionType: "QueryFailedError",
421
+ }),
422
+ );
423
+
424
+ // Per-exception guard finds nothing for THIS exception...
425
+ jest.spyOn(AIRunService, "findOneBy").mockResolvedValue(null);
426
+
427
+ /*
428
+ * ...but another exception group — same root cause, different
429
+ * interpolated value — has a non-terminal FixException run.
430
+ */
431
+ const similarExceptionId: ObjectID = ObjectID.generate();
432
+ jest.spyOn(AIRunService, "findBy").mockResolvedValue([
433
+ {
434
+ triggeredByTelemetryExceptionId: similarExceptionId,
435
+ } as unknown as AIRun,
436
+ ]);
437
+ jest.spyOn(TelemetryExceptionService, "findBy").mockResolvedValue([
438
+ {
439
+ _id: similarExceptionId.toString(),
440
+ message:
441
+ 'invalid input syntax for type uuid: "123e4567-e89b-12d3-a456-426614174000"',
442
+ exceptionType: "QueryFailedError",
443
+ } as unknown as TelemetryException,
444
+ ]);
445
+
446
+ const create: jest.SpyInstance = jest.spyOn(AIRunService, "create");
447
+
448
+ await expect(
449
+ TelemetryExceptionService.createCodeFixRunForException({
450
+ telemetryExceptionId: exceptionId,
451
+ props: { isRoot: true },
452
+ }),
453
+ ).rejects.toThrow(/similar exception/);
454
+
455
+ expect(create).not.toHaveBeenCalled();
456
+ });
457
+
458
+ test("cross-group duplicate guard: a DIFFERENT normalized message does not block", async () => {
459
+ mockReadinessOk();
460
+
461
+ jest.spyOn(AIRunService, "findOneBy").mockResolvedValue(null);
462
+
463
+ const otherExceptionId: ObjectID = ObjectID.generate();
464
+ jest.spyOn(AIRunService, "findBy").mockResolvedValue([
465
+ {
466
+ triggeredByTelemetryExceptionId: otherExceptionId,
467
+ } as unknown as AIRun,
468
+ ]);
469
+ jest.spyOn(TelemetryExceptionService, "findBy").mockResolvedValue([
470
+ {
471
+ _id: otherExceptionId.toString(),
472
+ message: "Connection refused to redis",
473
+ exceptionType: "ConnectionError",
474
+ } as unknown as TelemetryException,
475
+ ]);
476
+
477
+ const create: jest.SpyInstance = jest
478
+ .spyOn(AIRunService, "create")
479
+ .mockResolvedValue({ id: ObjectID.generate() } as unknown as AIRun);
480
+
481
+ await expect(
482
+ TelemetryExceptionService.createCodeFixRunForException({
483
+ telemetryExceptionId: exceptionId,
484
+ props: { isRoot: true },
485
+ }),
486
+ ).resolves.toBeDefined();
487
+
488
+ expect(create).toHaveBeenCalled();
489
+ });
286
490
  });
287
491
 
288
492
  /*