@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
@@ -33,15 +33,16 @@ import { describe, expect, test, afterEach, beforeEach } from "@jest/globals";
33
33
  * broken project, detector or insight never stops the rest), only NEWLY
34
34
  * created insights are routed — refreshed rows keep their status — and a
35
35
  * new insight always leaves the defensive Detected state in the same tick:
36
- * FixOpened + fixAiRunId when fix routing opened one, ActionRequired
37
- * otherwise, with triage enqueued for fix and non-fix insights alike.
36
+ * ActionRequired, with its triage enqueued. TRIAGE GATES THE FIX: the
37
+ * scanner NEVER calls fix routing the automatic fix decision moved to
38
+ * the triage runner, which routes a fix only on a code-fault verdict.
38
39
  *
39
40
  * Plus the self-heal sweep: a row an earlier tick created but never routed
40
41
  * (pod death, failed status write) is stranded in Detected forever — the
41
42
  * refresh path never re-routes it, and its fingerprint stays pinned. The
42
43
  * sweep re-routes it on the next tick, runs BEFORE the detectors (so this
43
44
  * tick's own rows cannot be routed twice), never breaks the scan when it
44
- * fails, and cannot double-create work (the fix/triage dedupes hold).
45
+ * fails, and cannot double-create work (the triage dedupe holds).
45
46
  */
46
47
 
47
48
  const projectId: ObjectID = ObjectID.generate();
@@ -301,29 +302,7 @@ describe("InsightScanner — routing newly created insights", () => {
301
302
  jest.restoreAllMocks();
302
303
  });
303
304
 
304
- test("fix routing returned a run status FixOpened + fixAiRunId persisted", async () => {
305
- const insight: AIInsight = fakeInsight();
306
- const fixAiRunId: ObjectID = ObjectID.generate();
307
- jest
308
- .spyOn(InsightStore, "upsertCandidates")
309
- .mockResolvedValue(emptyUpsertResult({ created: [insight] }));
310
- routeInsightFix.mockResolvedValue({ fixAiRunId });
311
-
312
- await InsightScanner.scanProjectForInsights(fakeProject());
313
-
314
- expect(updateOneById).toHaveBeenCalledWith(
315
- expect.objectContaining({
316
- id: insight.id,
317
- data: expect.objectContaining({
318
- status: AIInsightStatus.FixOpened,
319
- fixAiRunId,
320
- }),
321
- props: expect.objectContaining({ isRoot: true }),
322
- }),
323
- );
324
- });
325
-
326
- test("no fix opened → status ActionRequired", async () => {
305
+ test("a new insight lands on ActionRequired the scanner NEVER calls fix routing (triage gates the fix)", async () => {
327
306
  const insight: AIInsight = fakeInsight();
328
307
  jest
329
308
  .spyOn(InsightStore, "upsertCandidates")
@@ -331,6 +310,7 @@ describe("InsightScanner — routing newly created insights", () => {
331
310
 
332
311
  await InsightScanner.scanProjectForInsights(fakeProject());
333
312
 
313
+ expect(routeInsightFix).not.toHaveBeenCalled();
334
314
  expect(updateOneById).toHaveBeenCalledWith(
335
315
  expect.objectContaining({
336
316
  id: insight.id,
@@ -340,18 +320,7 @@ describe("InsightScanner — routing newly created insights", () => {
340
320
  props: expect.objectContaining({ isRoot: true }),
341
321
  }),
342
322
  );
343
- });
344
-
345
- test("the project (carrying its enableInsightFixTasks flag) is handed to fix routing — the flag gate lives there", async () => {
346
- const insight: AIInsight = fakeInsight();
347
- const project: Project = fakeProject();
348
- jest
349
- .spyOn(InsightStore, "upsertCandidates")
350
- .mockResolvedValue(emptyUpsertResult({ created: [insight] }));
351
-
352
- await InsightScanner.scanProjectForInsights(project);
353
-
354
- expect(routeInsightFix).toHaveBeenCalledWith({ insight, project });
323
+ expect(enqueueInsightTriage).toHaveBeenCalledWith({ insight });
355
324
  });
356
325
 
357
326
  test("triage run enqueued → triageAiRunId persisted", async () => {
@@ -418,27 +387,6 @@ describe("InsightScanner — routing newly created insights", () => {
418
387
  expect(create).not.toHaveBeenCalled();
419
388
  });
420
389
 
421
- test("fix routing rejecting (contract breach) degrades to ActionRequired and still triages", async () => {
422
- const insight: AIInsight = fakeInsight();
423
- jest
424
- .spyOn(InsightStore, "upsertCandidates")
425
- .mockResolvedValue(emptyUpsertResult({ created: [insight] }));
426
- routeInsightFix.mockRejectedValue(new Error("routing blew up"));
427
-
428
- await expect(
429
- InsightScanner.scanProjectForInsights(fakeProject()),
430
- ).resolves.toBeUndefined();
431
-
432
- expect(updateOneById).toHaveBeenCalledWith(
433
- expect.objectContaining({
434
- data: expect.objectContaining({
435
- status: AIInsightStatus.ActionRequired,
436
- }),
437
- }),
438
- );
439
- expect(enqueueInsightTriage).toHaveBeenCalledWith({ insight });
440
- });
441
-
442
390
  test("triage rejecting (contract breach) does not fail the scan — the status was already routed", async () => {
443
391
  const insight: AIInsight = fakeInsight();
444
392
  jest
@@ -473,9 +421,14 @@ describe("InsightScanner — routing newly created insights", () => {
473
421
  InsightScanner.scanProjectForInsights(fakeProject()),
474
422
  ).resolves.toBeUndefined();
475
423
 
476
- expect(routeInsightFix).toHaveBeenCalledTimes(2);
477
- expect(routeInsightFix).toHaveBeenCalledWith(
478
- expect.objectContaining({ insight: second }),
424
+ expect(enqueueInsightTriage).toHaveBeenCalledWith({ insight: second });
425
+ expect(updateOneById).toHaveBeenCalledWith(
426
+ expect.objectContaining({
427
+ id: second.id,
428
+ data: expect.objectContaining({
429
+ status: AIInsightStatus.ActionRequired,
430
+ }),
431
+ }),
479
432
  );
480
433
  });
481
434
  });
@@ -486,14 +439,42 @@ describe("InsightScanner — the self-heal sweep for insights stranded in Detect
486
439
  let routeInsightFix: jest.SpyInstance;
487
440
  let enqueueInsightTriage: jest.SpyInstance;
488
441
 
442
+ /*
443
+ * The sweep now issues THREE findBy queries per tick (Detected rows,
444
+ * untriaged ActionRequired rows, unrouted code-fault rows) — the mock
445
+ * dispatches on the queried status/classification so each test can feed
446
+ * exactly one stranded shape.
447
+ */
448
+ let detectedStranded: Array<AIInsight>;
449
+ let untriagedStranded: Array<AIInsight>;
450
+ let unroutedCodeFaults: Array<AIInsight>;
451
+
489
452
  beforeEach(() => {
453
+ detectedStranded = [];
454
+ untriagedStranded = [];
455
+ unroutedCodeFaults = [];
456
+
490
457
  // No candidates this tick: only the sweep is under test.
491
458
  jest
492
459
  .spyOn(InsightDetectors, "getAllDetectors")
493
460
  .mockReturnValue([fakeDetector({ candidates: [] })]);
494
461
  findBy = jest
495
462
  .spyOn(AIInsightService, "findBy")
496
- .mockResolvedValue([] as unknown as Array<AIInsight>);
463
+ .mockImplementation((findByArgs: unknown): Promise<Array<AIInsight>> => {
464
+ const query: Record<string, unknown> =
465
+ (findByArgs as { query: Record<string, unknown> }).query || {};
466
+
467
+ if (query["status"] === AIInsightStatus.Detected) {
468
+ return Promise.resolve(detectedStranded);
469
+ }
470
+ if (query["classification"]) {
471
+ return Promise.resolve(unroutedCodeFaults);
472
+ }
473
+ if (query["triageCompletedAt"]) {
474
+ return Promise.resolve(untriagedStranded);
475
+ }
476
+ return Promise.resolve([]);
477
+ });
497
478
  updateOneById = jest
498
479
  .spyOn(AIInsightService, "updateOneById")
499
480
  .mockResolvedValue(undefined);
@@ -511,7 +492,7 @@ describe("InsightScanner — the self-heal sweep for insights stranded in Detect
511
492
 
512
493
  test("a Detected row left over from an earlier tick is swept up and re-routed to ActionRequired (and triaged)", async () => {
513
494
  const stranded: AIInsight = fakeInsight();
514
- findBy.mockResolvedValue([stranded]);
495
+ detectedStranded = [stranded];
515
496
 
516
497
  await InsightScanner.scanProjectForInsights(fakeProject());
517
498
 
@@ -526,9 +507,8 @@ describe("InsightScanner — the self-heal sweep for insights stranded in Detect
526
507
  props: expect.objectContaining({ isRoot: true }),
527
508
  }),
528
509
  );
529
- expect(routeInsightFix).toHaveBeenCalledWith(
530
- expect.objectContaining({ insight: stranded }),
531
- );
510
+ // Fix routing is NOT part of scan-time routing — triage gates the fix.
511
+ expect(routeInsightFix).not.toHaveBeenCalled();
532
512
  expect(updateOneById).toHaveBeenCalledWith(
533
513
  expect.objectContaining({
534
514
  id: stranded.id,
@@ -541,34 +521,21 @@ describe("InsightScanner — the self-heal sweep for insights stranded in Detect
541
521
  expect(enqueueInsightTriage).toHaveBeenCalledWith({ insight: stranded });
542
522
  });
543
523
 
544
- test("a swept row whose fix routing opens a run lands on FixOpened + fixAiRunId — the full routing path, not a status patch", async () => {
545
- const stranded: AIInsight = fakeInsight();
546
- const fixAiRunId: ObjectID = ObjectID.generate();
547
- findBy.mockResolvedValue([stranded]);
548
- routeInsightFix.mockResolvedValue({ fixAiRunId });
549
-
550
- await InsightScanner.scanProjectForInsights(fakeProject());
551
-
552
- expect(updateOneById).toHaveBeenCalledWith(
553
- expect.objectContaining({
554
- id: stranded.id,
555
- data: expect.objectContaining({
556
- status: AIInsightStatus.FixOpened,
557
- fixAiRunId,
558
- }),
559
- }),
560
- );
561
- });
562
-
563
524
  test("the sweep runs BEFORE the detectors — so rows created by THIS tick are routed exactly once, never twice", async () => {
564
525
  const callOrder: Array<string> = [];
565
526
  const created: AIInsight = fakeInsight();
566
527
 
567
- findBy.mockImplementation((): Promise<Array<AIInsight>> => {
568
- callOrder.push("sweep");
569
- // Nothing stranded: the row below does not exist yet at sweep time.
570
- return Promise.resolve([]);
571
- });
528
+ findBy.mockImplementation(
529
+ (findByArgs: unknown): Promise<Array<AIInsight>> => {
530
+ const query: Record<string, unknown> =
531
+ (findByArgs as { query: Record<string, unknown> }).query || {};
532
+ if (query["status"] === AIInsightStatus.Detected) {
533
+ callOrder.push("sweep");
534
+ }
535
+ // Nothing stranded: the row below does not exist yet at sweep time.
536
+ return Promise.resolve([]);
537
+ },
538
+ );
572
539
  jest.spyOn(InsightDetectors, "getAllDetectors").mockReturnValue([
573
540
  {
574
541
  insightType: AIInsightType.NewException,
@@ -585,10 +552,8 @@ describe("InsightScanner — the self-heal sweep for insights stranded in Detect
585
552
  await InsightScanner.scanProjectForInsights(fakeProject());
586
553
 
587
554
  expect(callOrder).toEqual(["sweep", "detect"]);
588
- expect(routeInsightFix).toHaveBeenCalledTimes(1);
589
- expect(routeInsightFix).toHaveBeenCalledWith(
590
- expect.objectContaining({ insight: created }),
591
- );
555
+ expect(enqueueInsightTriage).toHaveBeenCalledTimes(1);
556
+ expect(enqueueInsightTriage).toHaveBeenCalledWith({ insight: created });
592
557
  });
593
558
 
594
559
  test("a sweep failure never breaks the scan — detectors still run and this tick's new insights still route", async () => {
@@ -606,15 +571,13 @@ describe("InsightScanner — the self-heal sweep for insights stranded in Detect
606
571
  ).resolves.toBeUndefined();
607
572
 
608
573
  expect(upsert).toHaveBeenCalledTimes(1);
609
- expect(routeInsightFix).toHaveBeenCalledWith(
610
- expect.objectContaining({ insight: created }),
611
- );
574
+ expect(enqueueInsightTriage).toHaveBeenCalledWith({ insight: created });
612
575
  });
613
576
 
614
577
  test("one stranded row failing to re-route does not stop the next one", async () => {
615
578
  const first: AIInsight = fakeInsight();
616
579
  const second: AIInsight = fakeInsight();
617
- findBy.mockResolvedValue([first, second]);
580
+ detectedStranded = [first, second];
618
581
  updateOneById
619
582
  .mockRejectedValueOnce(new Error("write conflict"))
620
583
  .mockResolvedValue(undefined);
@@ -623,31 +586,99 @@ describe("InsightScanner — the self-heal sweep for insights stranded in Detect
623
586
  InsightScanner.scanProjectForInsights(fakeProject()),
624
587
  ).resolves.toBeUndefined();
625
588
 
626
- expect(routeInsightFix).toHaveBeenCalledTimes(2);
589
+ expect(enqueueInsightTriage).toHaveBeenCalledWith({ insight: second });
590
+ });
591
+
592
+ test("stranded shape 2: an ActionRequired insight with no triage verdict gets its triage re-enqueued (capped on failed attempts)", async () => {
593
+ const untriaged: AIInsight = fakeInsight();
594
+ untriagedStranded = [untriaged];
595
+
596
+ // Zero failed attempts: the re-enqueue proceeds.
597
+ const countBy: jest.SpyInstance = jest
598
+ .spyOn(AIRunService, "countBy")
599
+ .mockResolvedValue({
600
+ toNumber: () => {
601
+ return 0;
602
+ },
603
+ } as never);
604
+ const triageAiRunId: ObjectID = ObjectID.generate();
605
+ enqueueInsightTriage.mockResolvedValue({ triageAiRunId });
606
+
607
+ await InsightScanner.scanProjectForInsights(fakeProject());
608
+
609
+ expect(enqueueInsightTriage).toHaveBeenCalledWith({ insight: untriaged });
610
+ expect(updateOneById).toHaveBeenCalledWith(
611
+ expect.objectContaining({
612
+ id: untriaged.id,
613
+ data: expect.objectContaining({ triageAiRunId }),
614
+ }),
615
+ );
616
+
617
+ // Three failed attempts: the insight is left alone (no retry storm).
618
+ enqueueInsightTriage.mockClear();
619
+ countBy.mockResolvedValue({
620
+ toNumber: () => {
621
+ return 3;
622
+ },
623
+ } as never);
624
+
625
+ await InsightScanner.scanProjectForInsights(fakeProject());
626
+
627
+ expect(enqueueInsightTriage).not.toHaveBeenCalled();
628
+ });
629
+
630
+ test("stranded shape 3: a code-fault verdict whose fix never routed is re-routed with a CONDITIONAL FixOpened flip", async () => {
631
+ const unrouted: AIInsight = {
632
+ ...fakeInsight(),
633
+ insightType: AIInsightType.NewException,
634
+ } as AIInsight;
635
+ unroutedCodeFaults = [unrouted];
636
+
637
+ const fixAiRunId: ObjectID = ObjectID.generate();
638
+ routeInsightFix.mockResolvedValue({ fixAiRunId });
639
+ const flipStatus: jest.SpyInstance = jest
640
+ .spyOn(AIInsightService, "updateOneBy")
641
+ .mockResolvedValue(undefined as never);
642
+
643
+ await InsightScanner.scanProjectForInsights(fakeProject());
644
+
627
645
  expect(routeInsightFix).toHaveBeenCalledWith(
628
- expect.objectContaining({ insight: second }),
646
+ expect.objectContaining({ insight: unrouted }),
647
+ );
648
+ expect(flipStatus).toHaveBeenCalledWith(
649
+ expect.objectContaining({
650
+ query: expect.objectContaining({
651
+ status: AIInsightStatus.ActionRequired,
652
+ }),
653
+ data: expect.objectContaining({
654
+ status: AIInsightStatus.FixOpened,
655
+ fixAiRunId,
656
+ }),
657
+ }),
629
658
  );
630
659
  });
631
660
  });
632
661
 
633
662
  /*
634
- * The safety proof for the sweep: re-routing a row that ALREADY got its fix
635
- * run (the pod died after the run was created but before the status write)
636
- * cannot create a second one. The dedupe lives inside the creation path
637
- * itself this exercises the REAL InsightFixRouting against it.
663
+ * The safety proof for the post-triage fix path: routing a fix for an
664
+ * insight whose exception ALREADY has a run (the pod died after the run was
665
+ * created but before the insight status write; or triage re-ran) cannot
666
+ * create a second one. The dedupe lives inside the creation path itself
667
+ * this exercises the REAL InsightFixRouting against it, exactly as the
668
+ * triage runner invokes it on a code-fault verdict.
638
669
  */
639
- describe("InsightScannerre-routing a stranded insight cannot double-create a fix run", () => {
670
+ describe("InsightFixRouting — a dedupe rejection from the creation path is a quiet no-fix", () => {
640
671
  afterEach(() => {
641
672
  jest.restoreAllMocks();
642
673
  });
643
674
 
644
- test("the creation path's per-(exception, recipe) dedupe rejection is a quiet no-fix: no run is stamped, the row lands on ActionRequired", async () => {
645
- const stranded: AIInsight = {
675
+ test("the creation path's per-(exception, recipe) dedupe rejection yields an empty result and stamps nothing", async () => {
676
+ const insight: AIInsight = {
646
677
  id: ObjectID.generate(),
647
678
  projectId,
648
679
  insightType: AIInsightType.NewException,
649
680
  telemetryExceptionId: ObjectID.generate(),
650
- status: AIInsightStatus.Detected,
681
+ status: AIInsightStatus.ActionRequired,
651
682
  } as unknown as AIInsight;
652
683
 
653
684
  const optedInProject: Project = {
@@ -665,24 +696,14 @@ describe("InsightScanner — re-routing a stranded insight cannot double-create
665
696
  runsToday: 0,
666
697
  };
667
698
 
668
- jest
669
- .spyOn(InsightDetectors, "getAllDetectors")
670
- .mockReturnValue([fakeDetector({ candidates: [] })]);
671
- jest
672
- .spyOn(AIInsightService, "findBy")
673
- .mockResolvedValue([stranded] as unknown as Array<AIInsight>);
674
- const updateOneById: jest.SpyInstance = jest
675
- .spyOn(AIInsightService, "updateOneById")
676
- .mockResolvedValue(undefined);
677
- jest.spyOn(InsightTriage, "enqueueInsightTriage").mockResolvedValue({});
678
699
  jest.spyOn(FixRunBudget, "getBudgetStatus").mockResolvedValue(budget);
679
700
  jest
680
701
  .spyOn(TelemetryExceptionService, "getAIFixReadiness")
681
702
  .mockResolvedValue({ ready: true, checks: [] });
682
703
 
683
704
  /*
684
- * The dedupe: the fix run created before the pod died is still
685
- * non-terminal, so the creation path refuses a second one.
705
+ * The dedupe: a fix run for this exception is still non-terminal, so
706
+ * the creation path refuses a second one.
686
707
  */
687
708
  const create: jest.SpyInstance = jest
688
709
  .spyOn(TelemetryExceptionService, "createCodeFixRunForException")
@@ -693,28 +714,16 @@ describe("InsightScanner — re-routing a stranded insight cannot double-create
693
714
  );
694
715
  const stamp: jest.SpyInstance = jest.spyOn(AIRunService, "updateOneById");
695
716
 
696
- await expect(
697
- InsightScanner.scanProjectForInsights(optedInProject),
698
- ).resolves.toBeUndefined();
717
+ const result: { fixAiRunId?: ObjectID | undefined } =
718
+ await InsightFixRouting.routeInsightFix({
719
+ insight,
720
+ project: optedInProject,
721
+ });
699
722
 
700
- // The creation path was asked exactly once — and it refused.
723
+ // The creation path was asked exactly once — and it refused, quietly.
701
724
  expect(create).toHaveBeenCalledTimes(1);
702
- // No second run exists, so nothing was stamped and nothing is FixOpened.
725
+ expect(result.fixAiRunId).toBeUndefined();
726
+ // No second run exists, so nothing was stamped.
703
727
  expect(stamp).not.toHaveBeenCalled();
704
- expect(updateOneById).toHaveBeenCalledWith(
705
- expect.objectContaining({
706
- id: stranded.id,
707
- data: expect.objectContaining({
708
- status: AIInsightStatus.ActionRequired,
709
- }),
710
- }),
711
- );
712
- expect(updateOneById).not.toHaveBeenCalledWith(
713
- expect.objectContaining({
714
- data: expect.objectContaining({
715
- status: AIInsightStatus.FixOpened,
716
- }),
717
- }),
718
- );
719
728
  });
720
729
  });