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.
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/PKG-INFO +3 -1
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/base.py +198 -19
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/cli.py +54 -3
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/confluence/__init__.py +527 -359
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/discord/__init__.py +646 -646
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/gitlab/__init__.py +749 -749
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/google_workspace/__init__.py +66 -3
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/hubspot/__init__.py +489 -489
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/intercom/__init__.py +631 -631
- cortexdb_connectors-0.2.12/cortexdb_connectors/jira/__init__.py +1099 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/linear/__init__.py +705 -705
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/pagerduty/__init__.py +298 -298
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/salesforce/__init__.py +399 -399
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/servicenow/__init__.py +460 -460
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/state.py +145 -145
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/teams/__init__.py +656 -656
- cortexdb_connectors-0.2.12/cortexdb_connectors/tldv/__init__.py +429 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/webhooks.py +70 -29
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/zendesk/__init__.py +496 -496
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/PKG-INFO +3 -1
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/SOURCES.txt +3 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/requires.txt +2 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/pyproject.toml +7 -2
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_jira_attachments.py +4 -1
- cortexdb_connectors-0.2.12/tests/test_jira_findings_0_2_11.py +660 -0
- cortexdb_connectors-0.2.12/tests/test_product_findings_0_2_12.py +205 -0
- cortexdb_connectors-0.2.12/tests/test_tldv_findings_0_2_12.py +266 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_webhooks.py +3 -1
- cortexdb_connectors-0.2.10/cortexdb_connectors/jira/__init__.py +0 -556
- cortexdb_connectors-0.2.10/cortexdb_connectors/tldv/__init__.py +0 -175
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/README.md +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/__init__.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/freshdesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/github/__init__.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/insights/__init__.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/insights/api.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/insights/detectors.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/insights/engine.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/notion/__init__.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/slack/__init__.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors/worker.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/cortexdb_connectors.egg-info/top_level.txt +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/setup.cfg +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_base.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_connector_coverage.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_github.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_ingest_e2e.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_jira_findings_0_2_9.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_slack.py +0 -0
- {cortexdb_connectors-0.2.10 → cortexdb_connectors-0.2.12}/tests/test_streaming_cursor.py +0 -0
- {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.
|
|
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.
|
|
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}:
|
|
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
|
-
|
|
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
|
|
500
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|