@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
@@ -7,12 +7,18 @@ import InstrumentationTaskTrigger from "../../../../../Server/Utils/AI/SRE/Instr
7
7
  import { ConfidenceSignal } from "../../../../../Server/Utils/AI/SRE/ConfidenceSignal";
8
8
  import { ObservabilityAssistantResult } from "../../../../../Server/Utils/AI/Chat/ObservabilityAssistant";
9
9
  import AIInsightService from "../../../../../Server/Services/AIInsightService";
10
+ import ProjectService from "../../../../../Server/Services/ProjectService";
11
+ import TelemetryExceptionService from "../../../../../Server/Services/TelemetryExceptionService";
10
12
  import IncidentFeedService from "../../../../../Server/Services/IncidentFeedService";
11
13
  import AlertFeedService from "../../../../../Server/Services/AlertFeedService";
12
14
  import { AI_INSIGHT_TRIAGE_FEATURE } from "../../../../../Server/Services/AIService";
15
+ import InsightFixRouting from "../../../../../Server/Utils/AI/SRE/Insights/FixRouting";
13
16
  import AIInsight from "../../../../../Models/DatabaseModels/AIInsight";
17
+ import Project from "../../../../../Models/DatabaseModels/Project";
14
18
  import AIInsightType from "../../../../../Types/AI/AIInsightType";
15
19
  import AIInsightSeverity from "../../../../../Types/AI/AIInsightSeverity";
20
+ import AIInsightStatus from "../../../../../Types/AI/AIInsightStatus";
21
+ import ExceptionAIClassification from "../../../../../Types/AI/ExceptionAIClassification";
16
22
  import ObjectID from "../../../../../Types/ObjectID";
17
23
  import { describe, expect, test, afterEach } from "@jest/globals";
18
24
 
@@ -34,9 +40,10 @@ const aiRunId: ObjectID = ObjectID.generate();
34
40
  const projectId: ObjectID = ObjectID.generate();
35
41
  const sentinelInsightId: ObjectID = ObjectID.generate();
36
42
 
37
- function makeInsight(): AIInsight {
43
+ function makeInsight(overrides: Partial<AIInsight> = {}): AIInsight {
38
44
  return {
39
45
  id: sentinelInsightId,
46
+ projectId,
40
47
  insightType: AIInsightType.ExceptionSpike,
41
48
  severity: AIInsightSeverity.High,
42
49
  title: "Spike: NullPointerException in checkout",
@@ -50,9 +57,34 @@ function makeInsight(): AIInsight {
50
57
  spikeMultiplier: 60,
51
58
  },
52
59
  },
60
+ ...overrides,
53
61
  } as unknown as AIInsight;
54
62
  }
55
63
 
64
+ /*
65
+ * Harness for the verdict-driven follow-through: the engine is mocked to
66
+ * immediately drive postAnalysis with the given analysis text, exactly the
67
+ * way the real engine hands over its result.
68
+ */
69
+ function driveTriageWithAnalysis(analysisMarkdown: string): void {
70
+ jest
71
+ .spyOn(AIInvestigationEngine, "executeRun")
72
+ .mockImplementation(
73
+ async (data: {
74
+ aiRunId: ObjectID;
75
+ projectId: ObjectID;
76
+ attemptCount: number;
77
+ request: InvestigationRequest;
78
+ }): Promise<void> => {
79
+ await data.request.postAnalysis({
80
+ analysisMarkdown,
81
+ confidence: makeConfidence(),
82
+ result: {} as unknown as ObservabilityAssistantResult,
83
+ });
84
+ },
85
+ );
86
+ }
87
+
56
88
  function makeConfidence(): ConfidenceSignal {
57
89
  return { confident: true, source: "classification" };
58
90
  }
@@ -218,4 +250,248 @@ describe("InsightTriageRunner.executeTriage", () => {
218
250
  expect(alertFeed).not.toHaveBeenCalled();
219
251
  expect(instrumentationTrigger).not.toHaveBeenCalled();
220
252
  });
253
+
254
+ test("a code-fault verdict stamps the exception, routes the fix post-triage, and flips the insight to FixOpened", async () => {
255
+ const telemetryExceptionId: ObjectID = ObjectID.generate();
256
+ jest
257
+ .spyOn(AIInsightService, "findOneById")
258
+ .mockResolvedValue(makeInsight({ telemetryExceptionId }));
259
+ const persistInsight: jest.SpyInstance = jest
260
+ .spyOn(AIInsightService, "updateOneById")
261
+ .mockResolvedValue(undefined);
262
+ const flipStatus: jest.SpyInstance = jest
263
+ .spyOn(AIInsightService, "updateOneBy")
264
+ .mockResolvedValue(undefined as never);
265
+ const stampException: jest.SpyInstance = jest
266
+ .spyOn(TelemetryExceptionService, "updateOneById")
267
+ .mockResolvedValue(undefined as never);
268
+ jest.spyOn(ProjectService, "findOneById").mockResolvedValue({
269
+ id: projectId,
270
+ enableAi: true,
271
+ enableInsightFixTasks: true,
272
+ autoArchiveNonActionableExceptions: false,
273
+ } as unknown as Project);
274
+ const fixAiRunId: ObjectID = ObjectID.generate();
275
+ const routeFix: jest.SpyInstance = jest
276
+ .spyOn(InsightFixRouting, "routeInsightFix")
277
+ .mockResolvedValue({ fixAiRunId });
278
+
279
+ driveTriageWithAnalysis(
280
+ "Root cause: null deref in checkout.\n\nClassification: code-fault",
281
+ );
282
+
283
+ await InsightTriageRunner.executeTriage({
284
+ aiRunId,
285
+ projectId,
286
+ sentinelInsightId,
287
+ attemptCount: 1,
288
+ });
289
+
290
+ // The verdict lands on the insight row alongside the summary.
291
+ expect(persistInsight).toHaveBeenCalledWith(
292
+ expect.objectContaining({
293
+ id: sentinelInsightId,
294
+ data: expect.objectContaining({
295
+ classification: ExceptionAIClassification.CodeFault,
296
+ }),
297
+ }),
298
+ );
299
+ // ...and on the exception group row.
300
+ expect(stampException).toHaveBeenCalledWith(
301
+ expect.objectContaining({
302
+ id: telemetryExceptionId,
303
+ data: expect.objectContaining({
304
+ aiClassification: ExceptionAIClassification.CodeFault,
305
+ }),
306
+ }),
307
+ );
308
+ /*
309
+ * The fix decision happens HERE, post-verdict — and flips the status
310
+ * CONDITIONALLY: only a still-ActionRequired insight becomes FixOpened.
311
+ */
312
+ expect(routeFix).toHaveBeenCalledTimes(1);
313
+ expect(flipStatus).toHaveBeenCalledWith(
314
+ expect.objectContaining({
315
+ query: expect.objectContaining({
316
+ status: AIInsightStatus.ActionRequired,
317
+ }),
318
+ data: expect.objectContaining({
319
+ status: AIInsightStatus.FixOpened,
320
+ fixAiRunId,
321
+ }),
322
+ }),
323
+ );
324
+ });
325
+
326
+ test("a human Dismissed/Resolved during triage blocks all automation — no fix, no archive, terminal status untouched", async () => {
327
+ const telemetryExceptionId: ObjectID = ObjectID.generate();
328
+
329
+ /*
330
+ * First read (context build) returns the snapshot; the SECOND read is
331
+ * actOnClassification's status re-check — the human dismissed the
332
+ * insight while the triage run executed.
333
+ */
334
+ jest
335
+ .spyOn(AIInsightService, "findOneById")
336
+ .mockResolvedValueOnce(makeInsight({ telemetryExceptionId }))
337
+ .mockResolvedValue(
338
+ makeInsight({
339
+ telemetryExceptionId,
340
+ status: AIInsightStatus.Dismissed,
341
+ }),
342
+ );
343
+ const persistInsight: jest.SpyInstance = jest
344
+ .spyOn(AIInsightService, "updateOneById")
345
+ .mockResolvedValue(undefined);
346
+ const flipStatus: jest.SpyInstance = jest
347
+ .spyOn(AIInsightService, "updateOneBy")
348
+ .mockResolvedValue(undefined as never);
349
+ const exceptionWrites: jest.SpyInstance = jest
350
+ .spyOn(TelemetryExceptionService, "updateOneById")
351
+ .mockResolvedValue(undefined as never);
352
+ const routeFix: jest.SpyInstance = jest.spyOn(
353
+ InsightFixRouting,
354
+ "routeInsightFix",
355
+ );
356
+
357
+ driveTriageWithAnalysis(
358
+ "A clear null deref.\n\nClassification: code-fault",
359
+ );
360
+
361
+ await InsightTriageRunner.executeTriage({
362
+ aiRunId,
363
+ projectId,
364
+ sentinelInsightId,
365
+ attemptCount: 1,
366
+ });
367
+
368
+ // The verdict still lands (summary + classification are metadata)...
369
+ expect(persistInsight).toHaveBeenCalledWith(
370
+ expect.objectContaining({
371
+ data: expect.objectContaining({
372
+ classification: ExceptionAIClassification.CodeFault,
373
+ }),
374
+ }),
375
+ );
376
+ // ...but no automation fires, and the terminal status is never touched.
377
+ expect(routeFix).not.toHaveBeenCalled();
378
+ expect(flipStatus).not.toHaveBeenCalled();
379
+ expect(exceptionWrites).toHaveBeenCalledTimes(1); // classification stamp only
380
+ expect(exceptionWrites).not.toHaveBeenCalledWith(
381
+ expect.objectContaining({
382
+ data: expect.objectContaining({ isArchived: true }),
383
+ }),
384
+ );
385
+ });
386
+
387
+ test("a user-error verdict NEVER routes a fix", async () => {
388
+ const telemetryExceptionId: ObjectID = ObjectID.generate();
389
+ jest
390
+ .spyOn(AIInsightService, "findOneById")
391
+ .mockResolvedValue(makeInsight({ telemetryExceptionId }));
392
+ jest.spyOn(AIInsightService, "updateOneById").mockResolvedValue(undefined);
393
+ const exceptionWrites: jest.SpyInstance = jest
394
+ .spyOn(TelemetryExceptionService, "updateOneById")
395
+ .mockResolvedValue(undefined as never);
396
+ jest.spyOn(ProjectService, "findOneById").mockResolvedValue({
397
+ id: projectId,
398
+ enableAi: true,
399
+ enableInsightFixTasks: true,
400
+ autoArchiveNonActionableExceptions: false,
401
+ } as unknown as Project);
402
+ const routeFix: jest.SpyInstance = jest.spyOn(
403
+ InsightFixRouting,
404
+ "routeInsightFix",
405
+ );
406
+
407
+ driveTriageWithAnalysis(
408
+ "The uuid in the URL was garbage user input.\n\nClassification: user-error",
409
+ );
410
+
411
+ await InsightTriageRunner.executeTriage({
412
+ aiRunId,
413
+ projectId,
414
+ sentinelInsightId,
415
+ attemptCount: 1,
416
+ });
417
+
418
+ expect(routeFix).not.toHaveBeenCalled();
419
+ // The verdict still lands on the exception; no archive (not a denial).
420
+ expect(exceptionWrites).toHaveBeenCalledTimes(1);
421
+ expect(exceptionWrites).toHaveBeenCalledWith(
422
+ expect.objectContaining({
423
+ data: expect.objectContaining({
424
+ aiClassification: ExceptionAIClassification.UserError,
425
+ }),
426
+ }),
427
+ );
428
+ });
429
+
430
+ test("an expected-denial verdict auto-archives the exception ONLY when the project opted in", async () => {
431
+ const telemetryExceptionId: ObjectID = ObjectID.generate();
432
+ jest
433
+ .spyOn(AIInsightService, "findOneById")
434
+ .mockResolvedValue(makeInsight({ telemetryExceptionId }));
435
+ jest.spyOn(AIInsightService, "updateOneById").mockResolvedValue(undefined);
436
+ const exceptionWrites: jest.SpyInstance = jest
437
+ .spyOn(TelemetryExceptionService, "updateOneById")
438
+ .mockResolvedValue(undefined as never);
439
+ const routeFix: jest.SpyInstance = jest.spyOn(
440
+ InsightFixRouting,
441
+ "routeInsightFix",
442
+ );
443
+
444
+ // Opted IN: the group gets archived.
445
+ jest.spyOn(ProjectService, "findOneById").mockResolvedValue({
446
+ id: projectId,
447
+ enableAi: true,
448
+ enableInsightFixTasks: true,
449
+ autoArchiveNonActionableExceptions: true,
450
+ } as unknown as Project);
451
+
452
+ driveTriageWithAnalysis(
453
+ "This is the paywall doing its job.\n\nClassification: expected-denial",
454
+ );
455
+
456
+ await InsightTriageRunner.executeTriage({
457
+ aiRunId,
458
+ projectId,
459
+ sentinelInsightId,
460
+ attemptCount: 1,
461
+ });
462
+
463
+ expect(routeFix).not.toHaveBeenCalled();
464
+ expect(exceptionWrites).toHaveBeenCalledWith(
465
+ expect.objectContaining({
466
+ id: telemetryExceptionId,
467
+ data: expect.objectContaining({
468
+ isArchived: true,
469
+ markedAsArchivedAt: expect.any(Date),
470
+ }),
471
+ }),
472
+ );
473
+
474
+ // Opted OUT: verdict stamp only, no archive write.
475
+ exceptionWrites.mockClear();
476
+ jest.spyOn(ProjectService, "findOneById").mockResolvedValue({
477
+ id: projectId,
478
+ enableAi: true,
479
+ enableInsightFixTasks: true,
480
+ autoArchiveNonActionableExceptions: false,
481
+ } as unknown as Project);
482
+
483
+ await InsightTriageRunner.executeTriage({
484
+ aiRunId,
485
+ projectId,
486
+ sentinelInsightId,
487
+ attemptCount: 1,
488
+ });
489
+
490
+ expect(exceptionWrites).toHaveBeenCalledTimes(1);
491
+ expect(exceptionWrites).not.toHaveBeenCalledWith(
492
+ expect.objectContaining({
493
+ data: expect.objectContaining({ isArchived: true }),
494
+ }),
495
+ );
496
+ });
221
497
  });
@@ -0,0 +1,70 @@
1
+ import {
2
+ normalizeExceptionText,
3
+ sanitizeExceptionMessage,
4
+ sanitizeStackTrace,
5
+ } from "../../../Server/Utils/Telemetry/ExceptionSanitizer";
6
+ import { describe, expect, test } from "@jest/globals";
7
+
8
+ /*
9
+ * The sanitizer feeds two very different consumers:
10
+ *
11
+ * - normalizeExceptionText is the FINGERPRINT normalizer (moved here from
12
+ * App's ExceptionUtil) — its replacement behavior must stay stable or
13
+ * every existing exception group re-fingerprints.
14
+ * - sanitizeExceptionMessage / sanitizeStackTrace guard the surfaces that
15
+ * leave the platform: LLM prompts, PR titles/bodies, commit messages.
16
+ */
17
+ describe("normalizeExceptionText", () => {
18
+ test("replaces dynamic tokens with placeholders", () => {
19
+ expect(
20
+ normalizeExceptionText(
21
+ 'invalid input syntax for type uuid: "550e8400-e29b-41d4-a716-446655440000"',
22
+ ),
23
+ ).toContain("<UUID>");
24
+
25
+ expect(
26
+ normalizeExceptionText("failed to email john.doe@example.com"),
27
+ ).toContain("<EMAIL>");
28
+
29
+ expect(normalizeExceptionText("connect ETIMEDOUT 10.2.3.4:5432")).toContain(
30
+ "<IP>",
31
+ );
32
+ });
33
+
34
+ test("keeps the static structure of the message", () => {
35
+ const normalized: string = normalizeExceptionText(
36
+ "Domain lookup failed for id=12345678",
37
+ );
38
+
39
+ expect(normalized).toContain("Domain lookup failed");
40
+ expect(normalized).not.toContain("12345678");
41
+ });
42
+
43
+ test("empty input stays empty", () => {
44
+ expect(normalizeExceptionText("")).toBe("");
45
+ });
46
+ });
47
+
48
+ describe("sanitizeExceptionMessage", () => {
49
+ test("strips interpolated user data and secrets", () => {
50
+ const sanitized: string = sanitizeExceptionMessage(
51
+ "Login failed for jane@customer.com with token ghp_0123456789abcdefghijklmnopqrstuvwxyz1234",
52
+ );
53
+
54
+ expect(sanitized).not.toContain("jane@customer.com");
55
+ expect(sanitized).not.toContain("ghp_0123456789");
56
+ expect(sanitized).toContain("Login failed");
57
+ });
58
+ });
59
+
60
+ describe("sanitizeStackTrace", () => {
61
+ test("preserves file:line frames while stripping PII", () => {
62
+ const sanitized: string = sanitizeStackTrace(
63
+ "Error: boom for user jane@customer.com\n at charge (/app/src/billing/charge.ts:12:5)",
64
+ );
65
+
66
+ // The code agent needs the frame intact to locate the defect.
67
+ expect(sanitized).toContain("/app/src/billing/charge.ts:12:5");
68
+ expect(sanitized).not.toContain("jane@customer.com");
69
+ });
70
+ });
@@ -0,0 +1,57 @@
1
+ import InsightTriageRunner from "../../../Server/Utils/AI/SRE/Insights/InsightTriageRunner";
2
+ import ExceptionAIClassification from "../../../Types/AI/ExceptionAIClassification";
3
+ import { describe, expect, test } from "@jest/globals";
4
+
5
+ /*
6
+ * The triage verdict gates the automatic fix lane, so the parser must fail
7
+ * CLOSED: anything unparseable reads as Unknown, and Unknown never routes
8
+ * a fix pull request.
9
+ */
10
+ describe("InsightTriageRunner.parseClassification", () => {
11
+ test("parses the mandatory trailing verdict line", () => {
12
+ expect(
13
+ InsightTriageRunner.parseClassification(
14
+ "Root cause: bad uuid in URL.\n\nClassification: user-error",
15
+ ),
16
+ ).toBe(ExceptionAIClassification.UserError);
17
+ });
18
+
19
+ test("tolerates markdown emphasis and mixed case", () => {
20
+ expect(
21
+ InsightTriageRunner.parseClassification(
22
+ "analysis...\n**Classification:** code-fault",
23
+ ),
24
+ ).toBe(ExceptionAIClassification.CodeFault);
25
+
26
+ expect(
27
+ InsightTriageRunner.parseClassification(
28
+ "analysis...\nCLASSIFICATION: expected-denial",
29
+ ),
30
+ ).toBe(ExceptionAIClassification.ExpectedDenial);
31
+ });
32
+
33
+ test("the LAST verdict line wins when the taxonomy is discussed earlier", () => {
34
+ const analysis: string = [
35
+ "The options were:",
36
+ "Classification: code-fault",
37
+ "...but the paywall check fired intentionally.",
38
+ "Classification: expected-denial",
39
+ ].join("\n");
40
+
41
+ expect(InsightTriageRunner.parseClassification(analysis)).toBe(
42
+ ExceptionAIClassification.ExpectedDenial,
43
+ );
44
+ });
45
+
46
+ test("fails CLOSED: missing, malformed, or empty input reads as unknown", () => {
47
+ expect(InsightTriageRunner.parseClassification("no verdict here")).toBe(
48
+ ExceptionAIClassification.Unknown,
49
+ );
50
+ expect(
51
+ InsightTriageRunner.parseClassification("Classification: maybe-a-bug"),
52
+ ).toBe(ExceptionAIClassification.Unknown);
53
+ expect(InsightTriageRunner.parseClassification("")).toBe(
54
+ ExceptionAIClassification.Unknown,
55
+ );
56
+ });
57
+ });
@@ -0,0 +1,141 @@
1
+ import TelemetryImprovementTaskTrigger from "../../../Server/Utils/AI/SRE/TelemetryImprovementTaskTrigger";
2
+ import SubjectCodeFixRun from "../../../Server/Utils/AI/SRE/SubjectCodeFixRun";
3
+ import ServiceService from "../../../Server/Services/ServiceService";
4
+ import Service from "../../../Models/DatabaseModels/Service";
5
+ import AIRun from "../../../Models/DatabaseModels/AIRun";
6
+ import CodeFixTaskType from "../../../Types/AI/CodeFixTaskType";
7
+ import ObjectID from "../../../Types/ObjectID";
8
+ import { describe, expect, test, afterEach, beforeEach } from "@jest/globals";
9
+
10
+ /*
11
+ * The service-scoped instrumentation-improvement triggers (ImproveLogging /
12
+ * ImproveTracing): human-triggered, so every unmet gate fails EARLY with a
13
+ * clear message — unsupported recipe, missing/foreign service, no
14
+ * GitHub-App repository, or a duplicate active run for the same service.
15
+ */
16
+
17
+ const projectId: ObjectID = ObjectID.generate();
18
+ const telemetryServiceId: ObjectID = ObjectID.generate();
19
+ const userId: ObjectID = ObjectID.generate();
20
+
21
+ function mockService(overrides: Partial<Service> = {}): void {
22
+ jest.spyOn(ServiceService, "findOneById").mockResolvedValue({
23
+ id: telemetryServiceId,
24
+ name: "checkout",
25
+ projectId: projectId,
26
+ ...overrides,
27
+ } as unknown as Service);
28
+ }
29
+
30
+ describe("TelemetryImprovementTaskTrigger.createTelemetryImprovementTask", () => {
31
+ beforeEach(() => {
32
+ jest
33
+ .spyOn(SubjectCodeFixRun, "hasGitHubAppConnectedRepository")
34
+ .mockResolvedValue(true);
35
+ jest
36
+ .spyOn(SubjectCodeFixRun, "findNonTerminalRunForTelemetryService")
37
+ .mockResolvedValue(null);
38
+ });
39
+
40
+ afterEach(() => {
41
+ jest.restoreAllMocks();
42
+ });
43
+
44
+ test("rejects non-improvement recipes before touching anything", async () => {
45
+ const findService: jest.SpyInstance = jest.spyOn(
46
+ ServiceService,
47
+ "findOneById",
48
+ );
49
+
50
+ await expect(
51
+ TelemetryImprovementTaskTrigger.createTelemetryImprovementTask({
52
+ projectId,
53
+ telemetryServiceId,
54
+ taskType: CodeFixTaskType.FixException,
55
+ userId,
56
+ }),
57
+ ).rejects.toThrow(/not a telemetry-improvement recipe/);
58
+
59
+ expect(findService).not.toHaveBeenCalled();
60
+ });
61
+
62
+ test("rejects a service from another project (root read, explicit tenant check)", async () => {
63
+ mockService({ projectId: ObjectID.generate() });
64
+
65
+ await expect(
66
+ TelemetryImprovementTaskTrigger.createTelemetryImprovementTask({
67
+ projectId,
68
+ telemetryServiceId,
69
+ taskType: CodeFixTaskType.ImproveLogging,
70
+ userId,
71
+ }),
72
+ ).rejects.toThrow(/not found/);
73
+ });
74
+
75
+ test("rejects when no GitHub-App-connected repository exists", async () => {
76
+ mockService();
77
+ jest
78
+ .spyOn(SubjectCodeFixRun, "hasGitHubAppConnectedRepository")
79
+ .mockResolvedValue(false);
80
+
81
+ await expect(
82
+ TelemetryImprovementTaskTrigger.createTelemetryImprovementTask({
83
+ projectId,
84
+ telemetryServiceId,
85
+ taskType: CodeFixTaskType.ImproveLogging,
86
+ userId,
87
+ }),
88
+ ).rejects.toThrow(/GitHub-App-connected repository/);
89
+ });
90
+
91
+ test("per-(service, recipe) dedupe: an active run for the service blocks a second one", async () => {
92
+ mockService();
93
+ jest
94
+ .spyOn(SubjectCodeFixRun, "findNonTerminalRunForTelemetryService")
95
+ .mockResolvedValue({ id: ObjectID.generate() } as unknown as AIRun);
96
+ const enqueue: jest.SpyInstance = jest.spyOn(
97
+ SubjectCodeFixRun,
98
+ "enqueueSubjectCodeFixRun",
99
+ );
100
+
101
+ await expect(
102
+ TelemetryImprovementTaskTrigger.createTelemetryImprovementTask({
103
+ projectId,
104
+ telemetryServiceId,
105
+ taskType: CodeFixTaskType.ImproveTracing,
106
+ userId,
107
+ }),
108
+ ).rejects.toThrow(/already queued or running/);
109
+
110
+ expect(enqueue).not.toHaveBeenCalled();
111
+ });
112
+
113
+ test("happy path: enqueues with the service captured in taskContext and the user attributed", async () => {
114
+ mockService();
115
+ const createdRun: AIRun = { id: ObjectID.generate() } as unknown as AIRun;
116
+ const enqueue: jest.SpyInstance = jest
117
+ .spyOn(SubjectCodeFixRun, "enqueueSubjectCodeFixRun")
118
+ .mockResolvedValue(createdRun);
119
+
120
+ const run: AIRun =
121
+ await TelemetryImprovementTaskTrigger.createTelemetryImprovementTask({
122
+ projectId,
123
+ telemetryServiceId,
124
+ taskType: CodeFixTaskType.ImproveLogging,
125
+ userId,
126
+ });
127
+
128
+ expect(run).toBe(createdRun);
129
+ expect(enqueue).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ projectId,
132
+ taskType: CodeFixTaskType.ImproveLogging,
133
+ userId,
134
+ taskContext: expect.objectContaining({
135
+ telemetryServiceId: telemetryServiceId.toString(),
136
+ serviceName: "checkout",
137
+ }),
138
+ }),
139
+ );
140
+ });
141
+ });