cortexdb-connectors 0.2.10__tar.gz → 0.2.12__tar.gz

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 (53) hide show
  1. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/PKG-INFO +3 -1
  2. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/base.py +198 -19
  3. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/cli.py +54 -3
  4. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/confluence/__init__.py +527 -359
  5. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/discord/__init__.py +646 -646
  6. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/gitlab/__init__.py +749 -749
  7. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/google_workspace/__init__.py +66 -3
  8. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/hubspot/__init__.py +489 -489
  9. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/intercom/__init__.py +631 -631
  10. cortexdb_connectors-0.2.12/cortexdb_connectors/jira/__init__.py +1099 -0
  11. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/linear/__init__.py +705 -705
  12. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/pagerduty/__init__.py +298 -298
  13. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/salesforce/__init__.py +399 -399
  14. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/servicenow/__init__.py +460 -460
  15. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/state.py +145 -145
  16. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/teams/__init__.py +656 -656
  17. cortexdb_connectors-0.2.12/cortexdb_connectors/tldv/__init__.py +429 -0
  18. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/webhooks.py +70 -29
  19. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/zendesk/__init__.py +496 -496
  20. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/PKG-INFO +3 -1
  21. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/SOURCES.txt +3 -0
  22. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/requires.txt +2 -0
  23. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/pyproject.toml +7 -2
  24. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_jira_attachments.py +4 -1
  25. cortexdb_connectors-0.2.12/tests/test_jira_findings_0_2_11.py +660 -0
  26. cortexdb_connectors-0.2.12/tests/test_product_findings_0_2_12.py +205 -0
  27. cortexdb_connectors-0.2.12/tests/test_tldv_findings_0_2_12.py +266 -0
  28. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_webhooks.py +3 -1
  29. cortexdb_connectors-0.2.10/cortexdb_connectors/jira/__init__.py +0 -556
  30. cortexdb_connectors-0.2.10/cortexdb_connectors/tldv/__init__.py +0 -175
  31. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/README.md +0 -0
  32. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/__init__.py +0 -0
  33. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/freshdesk/__init__.py +0 -0
  34. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/github/__init__.py +0 -0
  35. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/insights/__init__.py +0 -0
  36. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/insights/api.py +0 -0
  37. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/insights/detectors.py +0 -0
  38. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/insights/engine.py +0 -0
  39. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/notion/__init__.py +0 -0
  40. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/slack/__init__.py +0 -0
  41. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/worker.py +0 -0
  42. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
  43. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
  44. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/top_level.txt +0 -0
  45. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/setup.cfg +0 -0
  46. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_base.py +0 -0
  47. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_connector_coverage.py +0 -0
  48. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_github.py +0 -0
  49. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_ingest_e2e.py +0 -0
  50. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_jira_findings_0_2_9.py +0 -0
  51. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_slack.py +0 -0
  52. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_streaming_cursor.py +0 -0
  53. {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_webhooks_replay.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexdb-connectors
3
- Version: 0.2.10
3
+ Version: 0.2.12
4
4
  Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
5
5
  Author-email: CortexDB Team <team@cortexdb.ai>
6
6
  License-Expression: Apache-2.0
@@ -32,6 +32,7 @@ Provides-Extra: pagerduty
32
32
  Requires-Dist: pdpyras>=5.0; extra == "pagerduty"
33
33
  Provides-Extra: jira
34
34
  Requires-Dist: jira>=3.0; extra == "jira"
35
+ Requires-Dist: tzdata; extra == "jira"
35
36
  Provides-Extra: confluence
36
37
  Requires-Dist: atlassian-python-api>=3.0; extra == "confluence"
37
38
  Provides-Extra: notion
@@ -62,6 +63,7 @@ Requires-Dist: slack-sdk>=3.0; extra == "all"
62
63
  Requires-Dist: pygithub>=2.0; extra == "all"
63
64
  Requires-Dist: pdpyras>=5.0; extra == "all"
64
65
  Requires-Dist: jira>=3.0; extra == "all"
66
+ Requires-Dist: tzdata; extra == "all"
65
67
  Requires-Dist: atlassian-python-api>=3.0; extra == "all"
66
68
  Requires-Dist: notion-client>=2.0; extra == "all"
67
69
  Requires-Dist: aiohttp>=3.9; extra == "all"
@@ -36,6 +36,11 @@ _ACTOR_LOCAL_ILLEGAL = re.compile(r"[^A-Za-z0-9_.@-]")
36
36
  # never drop content silently.
37
37
  _MAX_CONTENT_BYTES = 10 * 1024 * 1024 # 10 MB
38
38
 
39
+ # Server-enforced ceiling on `idempotency_key` (cortex-types
40
+ # `EnvelopeError::IdempotencyKeyTooLong`). Over it, the write is refused with
41
+ # 422 INVALID_BODY — which stalls a sync cursor — so keys are built to fit.
42
+ _MAX_IDEM_BYTES = 64
43
+
39
44
 
40
45
  # ---------------------------------------------------------------------------
41
46
  # Enums
@@ -102,7 +107,7 @@ class VisibilityLevel(str, Enum):
102
107
  @dataclass(frozen=True)
103
108
  class Source:
104
109
  system: str
105
- connector_version: str = "0.2.10"
110
+ connector_version: str = "0.2.12"
106
111
 
107
112
 
108
113
  @dataclass(frozen=True)
@@ -258,6 +263,14 @@ class CortexConnector(ABC):
258
263
  or self.scope
259
264
  or self.actor
260
265
  )
266
+ # idempotency_key → server blob_id for every blob uploaded this run,
267
+ # and → event_id for everything ingested this run. A connector that
268
+ # reconciles source deletions persists these so a LATER run can name
269
+ # the event and purge the bytes of an item deleted at the source;
270
+ # neither id is discoverable after the fact (the blob id is only
271
+ # visible at upload time, and idempotency records expire in 24h).
272
+ self._uploaded_blobs: dict[str, str] = {}
273
+ self._ingested_events: dict[str, str] = {}
261
274
 
262
275
  def bind(self, *, actor: str | None = None, scope_template: str | None = None) -> "CortexConnector":
263
276
  """Set v1-only fields after construction. Returns self for chaining."""
@@ -313,8 +326,12 @@ class CortexConnector(ABC):
313
326
  if result.latest_success is None or raw.timestamp > result.latest_success:
314
327
  result.latest_success = raw.timestamp
315
328
  except Exception as exc: # noqa: BLE001
329
+ # Include the exception TYPE: httpx timeout/transport errors
330
+ # stringify to "" and produced blank error lines that hid the
331
+ # real failure from operators.
316
332
  result.errors.append(
317
- f"Error processing event {raw.external_id}: {exc}"
333
+ f"Error processing event {raw.external_id}: "
334
+ f"{type(exc).__name__}: {exc}"
318
335
  )
319
336
  if result.earliest_failure is None or raw.timestamp < result.earliest_failure:
320
337
  result.earliest_failure = raw.timestamp
@@ -380,6 +397,15 @@ class CortexConnector(ABC):
380
397
  labels.append(f"source:{episode.source.system}")
381
398
  if episode.visibility and episode.visibility.level:
382
399
  labels.append(f"visibility:{episode.visibility.level.value}")
400
+ # Non-org visibility carries WHO may see it (bounded — labels are
401
+ # metadata, not an unbounded roster). Recall metadata filters can
402
+ # then scope RESTRICTED/PRIVATE items to their principals.
403
+ if episode.visibility.level in (
404
+ VisibilityLevel.RESTRICTED,
405
+ VisibilityLevel.PRIVATE,
406
+ ):
407
+ for principal in episode.visibility.allowed_principals[:16]:
408
+ labels.append(f"visible_to={principal}")
383
409
  for ent in episode.entities:
384
410
  labels.append(f"{ent.entity_type}:{ent.entity_id}")
385
411
 
@@ -424,10 +450,7 @@ class CortexConnector(ABC):
424
450
  "modality": modality,
425
451
  "content": content,
426
452
  "context": context,
427
- "idempotency_key": (
428
- episode.idempotency_key
429
- or self._fallback_idem(episode, scope)
430
- ),
453
+ "idempotency_key": self._idem_key(episode, scope),
431
454
  }
432
455
  if episode.actor and episode.actor.id:
433
456
  # Source actor ids are not guaranteed bare: Jira Cloud accountIds
@@ -451,12 +474,58 @@ class CortexConnector(ABC):
451
474
  seed = f"{scope}|{src}|{episode.id}|{episode.content[:512]}"
452
475
  return f"conn:{src}:{hashlib.sha256(seed.encode('utf-8')).hexdigest()[:24]}"
453
476
 
477
+ def _idem_key(self, episode: Episode, scope: str) -> str:
478
+ """The key actually sent — the connector's key QUALIFIED BY SCOPE.
479
+
480
+ C4: the server namespaces idempotency records by the scope ROOT (the
481
+ first path segment) plus the caller, while the envelope it hashes
482
+ carries the FULL scope. So the same source item synced into two
483
+ sibling scopes under one root re-used one record with a different
484
+ body — `409 IDEMPOTENCY_CONFLICT`, and every event after the first
485
+ scope was silently dropped. Qualifying the key with the resolved
486
+ scope makes it mean what it always claimed: "this item, in this
487
+ scope".
488
+
489
+ The scope rides as an 8-hex-char digest because the wire field is
490
+ capped at `_MAX_IDEM_BYTES` and scope paths are unbounded; a key that
491
+ can't fit the suffix is replaced by a digest of (scope, key) rather
492
+ than truncated, since a truncated key would collide across items.
493
+
494
+ Same-scope semantics are unchanged: one item still maps to exactly
495
+ one key, so re-syncs replay and poll/webhook paths (which resolve to
496
+ the same scope) still share one key. `_fallback_idem` keys already
497
+ hash the scope in, so they pass through untouched.
498
+ """
499
+ key = episode.idempotency_key
500
+ if not key:
501
+ return self._fallback_idem(episode, scope)
502
+ digest = hashlib.sha256(scope.encode("utf-8")).hexdigest()
503
+ suffix = f"@{digest[:8]}"
504
+ if len(key) + len(suffix) <= _MAX_IDEM_BYTES:
505
+ return f"{key}{suffix}"
506
+ seed = f"{scope}|{key}".encode("utf-8")
507
+ return f"cx:{hashlib.sha256(seed).hexdigest()[:40]}"
508
+
454
509
  @staticmethod
455
510
  def _guard_content(text: str, episode: Episode) -> str:
456
511
  """Hard 10 MB ceiling on content. Real items never hit this; if one
457
512
  does we truncate on a UTF-8 boundary and log LOUDLY (never silent), so
458
- an operator can find out what was too big and why."""
459
- raw = text.encode("utf-8")
513
+ an operator can find out what was too big and why.
514
+
515
+ Also sanitizes text that cannot be UTF-8-encoded (lone surrogates —
516
+ e.g. from a source emitting broken UTF-16 pairs): unencodable code
517
+ points are replaced instead of raising a client-side
518
+ ``UnicodeEncodeError`` that fails the whole item (tl;dv T10)."""
519
+ try:
520
+ raw = text.encode("utf-8")
521
+ except UnicodeEncodeError:
522
+ logger.warning(
523
+ "content for idempotency_key=%s contained unencodable code "
524
+ "points (lone surrogates?) — replaced",
525
+ episode.idempotency_key,
526
+ )
527
+ text = text.encode("utf-8", errors="replace").decode("utf-8")
528
+ raw = text.encode("utf-8")
460
529
  if len(raw) <= _MAX_CONTENT_BYTES:
461
530
  return text
462
531
  capped = raw[:_MAX_CONTENT_BYTES].decode("utf-8", errors="ignore")
@@ -496,13 +565,14 @@ class CortexConnector(ABC):
496
565
  resp.raise_for_status()
497
566
  return resp.json()["blob_id"]
498
567
 
499
- async def experience_exists(self, idempotency_key: str, scope: str) -> bool:
500
- """Probe `GET /v1/experience/status` for a prior write with this key.
568
+ async def experience_status(
569
+ self, idempotency_key: str, scope: str
570
+ ) -> dict[str, Any]:
571
+ """`GET /v1/experience/status` for a prior write with this key.
501
572
 
502
- Used before attachment blob uploads so a re-sync doesn't store a
503
- fresh (orphaned) blob copy for an experience that will just replay.
504
- Best-effort: any probe failure returns False (upload proceeds — the
505
- experience write itself still dedups via its idempotency key).
573
+ Returns the server's status document (`{found, event_id, status,
574
+ …}`), or `{"found": False}` when the probe fails — callers treat an
575
+ unreachable probe as "unknown", never as proof of absence.
506
576
  """
507
577
  import httpx
508
578
 
@@ -514,10 +584,110 @@ class CortexConnector(ABC):
514
584
  headers=self._headers(),
515
585
  )
516
586
  if resp.status_code != 200:
517
- return False
518
- return bool(resp.json().get("found"))
587
+ return {"found": False}
588
+ body = resp.json()
589
+ return body if isinstance(body, dict) else {"found": False}
519
590
  except Exception: # noqa: BLE001
591
+ return {"found": False}
592
+
593
+ async def experience_exists(self, idempotency_key: str, scope: str) -> bool:
594
+ """Did a write with this key already land?
595
+
596
+ Used before attachment blob uploads so a re-sync doesn't store a
597
+ fresh (orphaned) blob copy for an experience that will just replay.
598
+ Best-effort: any probe failure returns False (upload proceeds — the
599
+ experience write itself still dedups via its idempotency key).
600
+ """
601
+ status = await self.experience_status(idempotency_key, scope)
602
+ return bool(status.get("found"))
603
+
604
+ # -- v1 deletion propagation ---------------------------------------------
605
+
606
+ async def forget_events(
607
+ self, event_ids: list[str], scope: str, *, reason: str = ""
608
+ ) -> None:
609
+ """Redact specific events via `POST /v1/forget` (`memory_ids` selector).
610
+
611
+ The precise form of deletion propagation: it names exactly the events
612
+ to remove, so nothing else in the scope is touched. Used when a source
613
+ item that was ingested as its own event disappears at the source.
614
+ """
615
+ import httpx
616
+
617
+ ids = [e for e in event_ids if e]
618
+ if not ids:
619
+ return
620
+ body = {
621
+ "scope": scope,
622
+ "selector": {"memory_ids": ids},
623
+ "cascade": "redact_events",
624
+ "reason": reason or "source deletion reconciled by connector",
625
+ }
626
+ async with httpx.AsyncClient(timeout=30.0) as client:
627
+ resp = await client.post(
628
+ f"{self.cortex_url}/v1/forget", json=body, headers=self._headers()
629
+ )
630
+ if resp.status_code >= 400:
631
+ logger.error(
632
+ "forget REJECTED by server: status=%s ids=%s body=%s",
633
+ resp.status_code, ids, resp.text[:300],
634
+ )
635
+ resp.raise_for_status()
636
+
637
+ async def forget_entity(
638
+ self, entity: str, scope: str, *, reason: str = ""
639
+ ) -> None:
640
+ """Redact everything about a deleted source entity (`about_entity`).
641
+
642
+ Used when the source hard-deletes a whole item (a Jira issue, a
643
+ Notion page) and every event mentioning it must go.
644
+ """
645
+ import httpx
646
+
647
+ body = {
648
+ "scope": scope,
649
+ "selector": {"about_entity": entity},
650
+ "cascade": "redact_events",
651
+ "reason": reason or f"source deleted {entity}",
652
+ }
653
+ async with httpx.AsyncClient(timeout=30.0) as client:
654
+ resp = await client.post(
655
+ f"{self.cortex_url}/v1/forget", json=body, headers=self._headers()
656
+ )
657
+ if resp.status_code >= 400:
658
+ logger.error(
659
+ "forget REJECTED by server: status=%s entity=%s body=%s",
660
+ resp.status_code, entity, resp.text[:300],
661
+ )
662
+ resp.raise_for_status()
663
+
664
+ async def delete_blob(self, blob_id: str) -> bool:
665
+ """`DELETE /v1/blobs/{id}` — purge uploaded bytes.
666
+
667
+ Redacting the blob_ref experience removes the memory; it does not
668
+ remove the stored object. A source-side file deletion has to purge
669
+ BOTH, or the deleted file stays byte-retrievable from CortexDB.
670
+ Returns True when the bytes are gone (deleted now, or already
671
+ absent — 404 is success for a delete). Raises on anything else so
672
+ the caller reports a failed purge instead of assuming one.
673
+ """
674
+ import httpx
675
+
676
+ if not blob_id:
520
677
  return False
678
+ async with httpx.AsyncClient(timeout=30.0) as client:
679
+ resp = await client.delete(
680
+ f"{self.cortex_url}/v1/blobs/{blob_id}", headers=self._headers()
681
+ )
682
+ if resp.status_code == 404:
683
+ return True
684
+ if resp.status_code >= 400:
685
+ logger.error(
686
+ "blob delete REJECTED by server: status=%s blob_id=%s body=%s",
687
+ resp.status_code, blob_id, resp.text[:300],
688
+ )
689
+ resp.raise_for_status()
690
+ return True
521
691
 
522
692
  async def ingest_episode(self, episode: Episode) -> str:
523
693
  """POST `/v1/experience` with the v1 envelope.
@@ -546,10 +716,10 @@ class CortexConnector(ABC):
546
716
  "explicitly. The actor must match the token's `sub` claim."
547
717
  )
548
718
 
719
+ scope = self._resolve_scope(episode)
720
+ key = self._idem_key(episode, scope)
549
721
  blob_id: str | None = None
550
722
  if episode.blob is not None and episode.blob.data:
551
- scope = self._resolve_scope(episode)
552
- key = episode.idempotency_key or self._fallback_idem(episode, scope)
553
723
  if await self.experience_exists(key, scope):
554
724
  logger.info(
555
725
  "attachment already ingested (idempotency_key=%s); "
@@ -560,6 +730,7 @@ class CortexConnector(ABC):
560
730
  blob_id = await self.upload_blob(
561
731
  episode.blob.data, episode.blob.mime_type
562
732
  )
733
+ self._uploaded_blobs[key] = blob_id
563
734
 
564
735
  envelope = self._envelope(episode, blob_id=blob_id)
565
736
  headers = self._headers()
@@ -584,4 +755,12 @@ class CortexConnector(ABC):
584
755
  )
585
756
  resp.raise_for_status()
586
757
  body = resp.json()
587
- return body.get("event_id") or body.get("id") or episode.id
758
+ event_id = body.get("event_id") or body.get("id")
759
+ if event_id:
760
+ # Remembered so a connector that reconciles source deletions can
761
+ # name this exact event later. The server's idempotency records
762
+ # expire after 24h, so a status probe cannot resolve the id of
763
+ # something ingested days ago — which is exactly when a source
764
+ # deletion shows up.
765
+ self._ingested_events[key] = event_id
766
+ return event_id or episode.id
@@ -80,7 +80,12 @@ CONNECTORS: dict[str, dict[str, Any]] = {
80
80
  ("jira_email", "JIRA_EMAIL"),
81
81
  ("jira_api_token", "JIRA_API_TOKEN"),
82
82
  ],
83
- "options": [("project_keys", "JIRA_PROJECT_KEYS", "list")],
83
+ "options": [
84
+ ("project_keys", "JIRA_PROJECT_KEYS", "list"),
85
+ ("reconcile_deletions", "JIRA_RECONCILE_DELETIONS", "bool"),
86
+ ("reconcile_interval_seconds", "JIRA_RECONCILE_INTERVAL_SECONDS", "int"),
87
+ ("ledger_path", "JIRA_LEDGER_PATH", "str"),
88
+ ],
84
89
  },
85
90
  "freshdesk": {
86
91
  "import": "cortexdb_connectors.freshdesk:FreshdeskConnector",
@@ -260,6 +265,7 @@ def _load_cortex_creds(args: argparse.Namespace) -> dict[str, str]:
260
265
 
261
266
  _OPTION_PARSERS: dict[str, Callable[[str], Any]] = {
262
267
  "str": lambda v: v,
268
+ "int": int,
263
269
  "list": lambda v: [x.strip() for x in v.split(",") if x.strip()],
264
270
  "list-int": lambda v: [int(x.strip()) for x in v.split(",") if x.strip()],
265
271
  "bool": lambda v: v.lower() in ("1", "true", "yes", "on"),
@@ -405,13 +411,52 @@ def _cmd_sync(args: argparse.Namespace) -> None:
405
411
  # would be silently skipped on the next incremental run. Hold the cursor
406
412
  # just before the earliest failure so it (and everything after) is
407
413
  # re-attempted; on a clean run advance to the latest item we ingested.
414
+ #
415
+ # T15 (dead-letter cap): "hold forever" turns one PERMANENTLY-failing
416
+ # item into a head-of-line block — the cursor can never pass it, and
417
+ # every later item is refetched every cycle for eternity. Track how many
418
+ # consecutive cycles the cursor has been pinned at the same failure; at
419
+ # _DEAD_LETTER_AFTER, advance past it with a LOUD error naming what was
420
+ # abandoned, so the sync heals and the operator has a precise artifact
421
+ # to chase.
422
+ _DEAD_LETTER_AFTER = 5
423
+ state = state_store.get(args.connector, args.scope)
408
424
  if result.earliest_failure is not None:
409
- new_cursor = result.earliest_failure - timedelta(seconds=1)
425
+ pinned_at = result.earliest_failure.isoformat()
426
+ streak = int(state.extra.get("failure_streak", 0)) if (
427
+ state.extra.get("failure_pinned_at") == pinned_at
428
+ ) else 0
429
+ streak += 1
430
+ if streak >= _DEAD_LETTER_AFTER:
431
+ logger.error(
432
+ "DEAD-LETTER: item(s) at %s failed %d consecutive cycles — "
433
+ "advancing the cursor past them. They will NOT be retried; "
434
+ "see the per-item errors above for what was abandoned.",
435
+ pinned_at,
436
+ streak,
437
+ )
438
+ new_cursor = (
439
+ result.latest_success
440
+ if result.latest_success is not None
441
+ and result.latest_success > result.earliest_failure
442
+ else result.earliest_failure + timedelta(seconds=1)
443
+ )
444
+ state.extra.pop("failure_pinned_at", None)
445
+ state.extra.pop("failure_streak", None)
446
+ else:
447
+ new_cursor = result.earliest_failure - timedelta(seconds=1)
448
+ state.extra["failure_pinned_at"] = pinned_at
449
+ state.extra["failure_streak"] = streak
410
450
  elif result.latest_success is not None:
411
451
  new_cursor = result.latest_success
452
+ state.extra.pop("failure_pinned_at", None)
453
+ state.extra.pop("failure_streak", None)
412
454
  else:
413
455
  new_cursor = None # empty window — nothing to miss; default to now()
414
- state_store.update_last_synced(args.connector, args.scope, timestamp=new_cursor)
456
+ state.extra.pop("failure_pinned_at", None)
457
+ state.extra.pop("failure_streak", None)
458
+ state.last_synced_at = new_cursor or datetime.now(timezone.utc)
459
+ state_store.save(state)
415
460
 
416
461
  logger.info(
417
462
  "sync done fetched=%d ingested=%d skipped=%d errors=%d duration=%.1fs",
@@ -477,6 +522,12 @@ def _cmd_watch(args: argparse.Namespace) -> None:
477
522
  except SystemExit as exc:
478
523
  # Don't exit the watch loop on per-cycle errors.
479
524
  logger.error("cycle failed: %s", exc)
525
+ except Exception: # noqa: BLE001
526
+ # T6: an uncaught exception from fetch_events (e.g. a transient
527
+ # 429/5xx from the source API surviving the connector's retries)
528
+ # used to TERMINATE the watch daemon. A watcher's whole job is to
529
+ # outlive transient failures — log and take the next cycle.
530
+ logger.exception("cycle failed with unexpected error; continuing")
480
531
  logger.info("sleeping %ds before next cycle", args.interval)
481
532
  time.sleep(args.interval)
482
533