| 67 | STATUS_DEGRADED = "degraded" |
67 | STATUS_DEGRADED = "degraded" |
| 68 | STATUS_BROKEN = "broken" |
68 | STATUS_BROKEN = "broken" |
| 69 | STATUS_STALE = "stale" |
69 | STATUS_STALE = "stale" |
| 70 | −ALL_STATUSES = (STATUS_OK, STATUS_DEGRADED, STATUS_BROKEN, STATUS_STALE) |
70 | +# Source retirée de la supervision active : désactivée explicitement par |
| |
71 | +# l'app (``disabled_stores``/``disabled_sources`` du /api/stats) ou disparue |
| |
72 | +# de toute fenêtre de sync depuis > RETIRED_AFTER_DAYS. Sans ce statut, la |
| |
73 | +# ligne restait broken/stale à perpétuité (le registre est persisté, jamais |
| |
74 | +# purgé) et les agents gardiens missionnaient sans fin des sources mortes |
| |
75 | +# (2026-08-24 : 37 missions ka6 sur origin-north.ai, connecteur désactivé). |
| |
76 | +# La ligne se réévalue normalement si la source réapparaît dans le journal. |
| |
77 | +STATUS_RETIRED = "retired" |
| |
78 | +ALL_STATUSES = (STATUS_OK, STATUS_DEGRADED, STATUS_BROKEN, STATUS_STALE, STATUS_RETIRED) |
| |
79 | + |
| |
80 | +# Retrait après N jours sans apparaître dans aucune fenêtre de sync (marge |
| |
81 | +# large au-dessus des rotations observées, ~11-30 h selon les apps). |
| |
82 | +RETIRED_AFTER_DAYS = 14 |
| 71 | |
83 | |
| 72 | # broken après N échecs (ou 0 résultat) consécutifs. |
84 | # broken après N échecs (ou 0 résultat) consécutifs. |
| 73 | BROKEN_THRESHOLD = 3 |
85 | BROKEN_THRESHOLD = 3 |
| 110 | median_found: float | None = None |
122 | median_found: float | None = None |
| 111 | consecutive_failures: int = 0 |
123 | consecutive_failures: int = 0 |
| 112 | checked_at: datetime.datetime | None = None |
124 | checked_at: datetime.datetime | None = None |
| |
125 | + last_seen: datetime.datetime | None = None |
| 113 | |
126 | |
| 114 | |
127 | |
| 115 | @dataclass |
128 | @dataclass |
| 397 | median_found=row.median_found, |
410 | median_found=row.median_found, |
| 398 | consecutive_failures=row.consecutive_failures or 0, |
411 | consecutive_failures=row.consecutive_failures or 0, |
| 399 | checked_at=_ensure_utc(row.checked_at), |
412 | checked_at=_ensure_utc(row.checked_at), |
| |
413 | + last_seen=_ensure_utc(row.last_seen), |
| 400 | ) |
414 | ) |
| 401 | |
415 | |
| 402 | |
416 | |
| 415 | source: str, |
429 | source: str, |
| 416 | assessment: Assessment, |
430 | assessment: Assessment, |
| 417 | now: float, |
431 | now: float, |
| |
432 | + seen: bool = False, |
| 418 | ) -> str: |
433 | ) -> str: |
| 419 | − """Upsert de la ligne connector_health + alerte sur transition broken/stale.""" |
434 | + """Upsert de la ligne connector_health + alerte sur transition broken/stale. |
| |
435 | + |
| |
436 | + ``seen`` indique que la source est apparue dans la fenêtre de sync de ce |
| |
437 | + run : ``last_seen`` est alors horodaté — c'est lui qui pilote le retrait |
| |
438 | + (``retired``) des sources disparues du journal depuis > RETIRED_AFTER_DAYS. |
| |
439 | + """ |
| 420 | row = rows.get(source) |
440 | row = rows.get(source) |
| 421 | previous_status = row.status if row is not None else None |
441 | previous_status = row.status if row is not None else None |
| 422 | |
442 | |
| 425 | row = ConnectorHealth(service=service, source=source) |
445 | row = ConnectorHealth(service=service, source=source) |
| 426 | session.add(row) |
446 | session.add(row) |
| 427 | rows[source] = row |
447 | rows[source] = row |
| |
448 | + row.last_seen = checked_at |
| |
449 | + if seen: |
| |
450 | + row.last_seen = checked_at |
| 428 | row.checked_at = checked_at |
451 | row.checked_at = checked_at |
| 429 | row.status = assessment.status |
452 | row.status = assessment.status |
| 430 | row.last_success = assessment.last_success |
453 | row.last_success = assessment.last_success |
| 475 | # de 2 h, fabrika en bord de rotation de ~11 h pour un seuil de 24 h). |
498 | # de 2 h, fabrika en bord de rotation de ~11 h pour un seuil de 24 h). |
| 476 | source_stale_after = app_stale_after * (rotation_factor(payload, by_source) + 1) |
499 | source_stale_after = app_stale_after * (rotation_factor(payload, by_source) + 1) |
| 477 | |
500 | |
| |
501 | + # Sources désactivées déclarées par l'app elle-même (signal explicite, |
| |
502 | + # ex. ``disabled_stores`` de fabri-ka) : retrait immédiat. |
| |
503 | + disabled = { |
| |
504 | + str(s) |
| |
505 | + for s in (payload.get("disabled_stores") or payload.get("disabled_sources") or []) |
| |
506 | + if s |
| |
507 | + } |
| |
508 | + |
| 478 | with session_scope() as session: |
509 | with session_scope() as session: |
| 479 | rows = _load_rows(session, service) |
510 | rows = _load_rows(session, service) |
| 480 | |
511 | |
| 481 | assessment = assess_source( |
512 | assessment = assess_source( |
| 482 | [app_entry], _state_from_row(rows.get(APP_SOURCE)), now, app_stale_after |
513 | [app_entry], _state_from_row(rows.get(APP_SOURCE)), now, app_stale_after |
| 483 | ) |
514 | ) |
| 484 | − counts[_apply(session, rows, service, APP_SOURCE, assessment, now)] += 1 |
515 | + counts[_apply(session, rows, service, APP_SOURCE, assessment, now, seen=True)] += 1 |
| 485 | |
516 | |
| 486 | for source, entries in by_source.items(): |
517 | for source, entries in by_source.items(): |
| 487 | assessment = assess_source( |
518 | assessment = assess_source( |
| 488 | entries, _state_from_row(rows.get(source)), now, source_stale_after |
519 | entries, _state_from_row(rows.get(source)), now, source_stale_after |
| 489 | ) |
520 | ) |
| 490 | − counts[_apply(session, rows, service, source, assessment, now)] += 1 |
521 | + counts[_apply(session, rows, service, source, assessment, now, seen=True)] += 1 |
| 491 | |
522 | |
| 492 | − # Sources connues mais absentes de la fenêtre courante : réévaluer la |
523 | + # Sources connues mais absentes de la fenêtre courante : retrait si |
| 493 | − # staleness (le streak et la médiane restent inchangés). |
524 | + # désactivées par l'app ou disparues du journal depuis trop longtemps, |
| |
525 | + # sinon réévaluer la staleness (streak et médiane inchangés). |
| 494 | for source in list(rows): |
526 | for source in list(rows): |
| 495 | if source == APP_SOURCE or source in by_source: |
527 | if source == APP_SOURCE or source in by_source: |
| 496 | continue |
528 | continue |
| 497 | − assessment = assess_source( |
529 | + prev = _state_from_row(rows.get(source)) |
| 498 | − [], _state_from_row(rows.get(source)), now, source_stale_after |
530 | + retired_reason = None |
| 499 | − ) |
531 | + if source in disabled: |
| |
532 | + retired_reason = "désactivée par l'app (liste disabled du /api/stats)" |
| |
533 | + elif prev.last_seen is not None and ( |
| |
534 | + now - prev.last_seen.timestamp() > RETIRED_AFTER_DAYS * 86400 |
| |
535 | + ): |
| |
536 | + days = (now - prev.last_seen.timestamp()) / 86400 |
| |
537 | + retired_reason = ( |
| |
538 | + f"absente de tout journal de sync depuis {days:.0f} j " |
| |
539 | + f"(seuil : {RETIRED_AFTER_DAYS} j)" |
| |
540 | + ) |
| |
541 | + if retired_reason is not None: |
| |
542 | + assessment = Assessment( |
| |
543 | + status=STATUS_RETIRED, |
| |
544 | + last_success=prev.last_success, |
| |
545 | + found_last=prev.found_last, |
| |
546 | + median_found=prev.median_found, |
| |
547 | + consecutive_failures=prev.consecutive_failures, |
| |
548 | + message=f"retirée de la supervision — {retired_reason}", |
| |
549 | + ) |
| |
550 | + else: |
| |
551 | + assessment = assess_source([], prev, now, source_stale_after) |
| 500 | counts[_apply(session, rows, service, source, assessment, now)] += 1 |
552 | counts[_apply(session, rows, service, source, assessment, now)] += 1 |
| 501 | |
553 | |
| 502 | return { |
554 | return { |