nthlayer-workers 1.0.0__py3-none-any.whl
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.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# src/nthlayer_respond/types.py
|
|
2
|
+
"""Core data types for Mayday."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IncidentState(str, Enum):
|
|
10
|
+
TRIGGERED = "triggered"
|
|
11
|
+
TRIAGING = "triaging"
|
|
12
|
+
INVESTIGATING = "investigating"
|
|
13
|
+
REMEDIATING = "remediating"
|
|
14
|
+
AWAITING_APPROVAL = "awaiting_approval"
|
|
15
|
+
RESOLVED = "resolved"
|
|
16
|
+
ESCALATED = "escalated"
|
|
17
|
+
FAILED = "failed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
TERMINAL_STATES = frozenset({
|
|
21
|
+
IncidentState.RESOLVED,
|
|
22
|
+
IncidentState.ESCALATED,
|
|
23
|
+
IncidentState.FAILED,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgentRole(str, Enum):
|
|
28
|
+
TRIAGE = "triage"
|
|
29
|
+
INVESTIGATION = "investigation"
|
|
30
|
+
COMMUNICATION = "communication"
|
|
31
|
+
REMEDIATION = "remediation"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TriageResult:
|
|
36
|
+
severity: int # 0-4 (P0-P4)
|
|
37
|
+
blast_radius: list[str]
|
|
38
|
+
affected_slos: list[str]
|
|
39
|
+
assigned_team: str | None
|
|
40
|
+
reasoning: str
|
|
41
|
+
confidence: float | None = None # from model response; None = not yet parsed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Hypothesis:
|
|
46
|
+
description: str
|
|
47
|
+
confidence: float # 0.0-1.0
|
|
48
|
+
evidence: list[str]
|
|
49
|
+
change_candidate: str | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class InvestigationResult:
|
|
54
|
+
hypotheses: list[Hypothesis]
|
|
55
|
+
root_cause: str | None
|
|
56
|
+
root_cause_confidence: float
|
|
57
|
+
reasoning: str
|
|
58
|
+
confidence: float | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class CommunicationUpdate:
|
|
63
|
+
channel: str
|
|
64
|
+
timestamp: str # ISO 8601
|
|
65
|
+
update_type: str # "initial" or "resolution"
|
|
66
|
+
content: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class CommunicationResult:
|
|
71
|
+
updates_sent: list[CommunicationUpdate] = field(default_factory=list)
|
|
72
|
+
reasoning: str = ""
|
|
73
|
+
confidence: float | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class RemediationResult:
|
|
78
|
+
proposed_action: str | None = None
|
|
79
|
+
target: str | None = None
|
|
80
|
+
risk_assessment: str = ""
|
|
81
|
+
requires_human_approval: bool = True
|
|
82
|
+
executed: bool = False
|
|
83
|
+
execution_result: str | None = None
|
|
84
|
+
autonomy_reduced: bool = False
|
|
85
|
+
autonomy_target: str | None = None
|
|
86
|
+
previous_autonomy_level: str | None = None
|
|
87
|
+
new_autonomy_level: str | None = None
|
|
88
|
+
reasoning: str = ""
|
|
89
|
+
autonomy_reduction: dict | None = None
|
|
90
|
+
confidence: float | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class IncidentContext:
|
|
95
|
+
id: str # INC-YYYY-NNNN
|
|
96
|
+
state: IncidentState
|
|
97
|
+
created_at: str # ISO 8601
|
|
98
|
+
updated_at: str
|
|
99
|
+
trigger_source: str # "nthlayer-correlate", "pagerduty", "manual"
|
|
100
|
+
trigger_verdict_ids: list[str]
|
|
101
|
+
topology: dict
|
|
102
|
+
triage: TriageResult | None = None
|
|
103
|
+
investigation: InvestigationResult | None = None
|
|
104
|
+
communication: CommunicationResult | None = None
|
|
105
|
+
remediation: RemediationResult | None = None
|
|
106
|
+
verdict_chain: list[str] = field(default_factory=list)
|
|
107
|
+
last_completed_step_index: int | None = None # pipeline step 0-3
|
|
108
|
+
error: str | None = None
|
|
109
|
+
metadata: dict = field(default_factory=dict)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Worker-mode verdict submission to core (P3-E.1).
|
|
2
|
+
|
|
3
|
+
Wraps a Verdict dataclass in a CloudEvents v1.0 envelope and submits it to
|
|
4
|
+
core via CoreAPIClient. Submission failure is logged and metered but does
|
|
5
|
+
not crash the cycle — the Verdict object is already constructed locally
|
|
6
|
+
and lineage is intact in IncidentContext.verdict_chain, so the next agent's
|
|
7
|
+
verdict will still chain correctly.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from nthlayer_common.api_client import CoreAPIClient
|
|
14
|
+
from nthlayer_common.cloudevents import wrap_verdict
|
|
15
|
+
from nthlayer_common.metrics import errors_total
|
|
16
|
+
from nthlayer_common.verdicts import Verdict, to_dict
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def submit_verdict_to_core(
|
|
22
|
+
client: CoreAPIClient,
|
|
23
|
+
verdict: Verdict,
|
|
24
|
+
*,
|
|
25
|
+
deployment_id: str | None = None,
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Submit a verdict to core via the CloudEvents-wrapped /verdicts endpoint.
|
|
28
|
+
|
|
29
|
+
Returns True on success, False on submission failure. The caller is
|
|
30
|
+
responsible for deciding whether to chain subsequent verdicts to a
|
|
31
|
+
failed one — current respond callers (``_emit_verdict``) skip appending
|
|
32
|
+
on failure so ``verdict_chain[-1]`` always references a verdict that
|
|
33
|
+
actually exists in core. This keeps the in-memory chain consistent with
|
|
34
|
+
the core-side lineage graph; otherwise consumers walking ancestry via
|
|
35
|
+
``parent_ids`` would dead-end on dangling references.
|
|
36
|
+
|
|
37
|
+
Failures are logged and counted; this function never raises. Operator
|
|
38
|
+
visibility comes from the errors_total Prometheus metric
|
|
39
|
+
(component="respond", error_type="verdict_submit").
|
|
40
|
+
"""
|
|
41
|
+
envelope = wrap_verdict(
|
|
42
|
+
to_dict(verdict),
|
|
43
|
+
component="respond",
|
|
44
|
+
deployment_id=deployment_id,
|
|
45
|
+
)
|
|
46
|
+
result = await client.submit_verdict(envelope["data"])
|
|
47
|
+
if not result.ok:
|
|
48
|
+
logger.warning(
|
|
49
|
+
"respond_verdict_submit_failed",
|
|
50
|
+
verdict_id=verdict.id,
|
|
51
|
+
status_code=result.status_code,
|
|
52
|
+
error=result.error,
|
|
53
|
+
)
|
|
54
|
+
errors_total.labels(component="respond", error_type="verdict_submit").inc()
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""RespondModule — worker module driving incident response (P3-E.1).
|
|
2
|
+
|
|
3
|
+
Polls correlation_snapshot assessments (primary) and quality_breach verdicts
|
|
4
|
+
(fallback) from core via CoreAPIClient. Opens incidents, runs the agent
|
|
5
|
+
pipeline via Coordinator, submits verdicts back to core, persists incident
|
|
6
|
+
state to component_state for crash recovery.
|
|
7
|
+
|
|
8
|
+
The worker is the sole owner of in-memory incident state. Agents and the
|
|
9
|
+
coordinator have NO back-reference to the worker — `self._incidents` is
|
|
10
|
+
mutated only by `_drive_active_incidents` after `coordinator.run(context)`
|
|
11
|
+
completes for each incident, and by `_ingest_triggers` before the drive phase.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
|
|
18
|
+
import structlog
|
|
19
|
+
|
|
20
|
+
from nthlayer_common.api_client import CoreAPIClient
|
|
21
|
+
|
|
22
|
+
from nthlayer_workers.respond.config import RespondConfig
|
|
23
|
+
from nthlayer_workers.respond.context_store import (
|
|
24
|
+
incident_context_from_dict,
|
|
25
|
+
incident_context_to_dict,
|
|
26
|
+
)
|
|
27
|
+
from nthlayer_workers.respond.coordinator import Coordinator
|
|
28
|
+
from nthlayer_workers.respond.types import (
|
|
29
|
+
IncidentContext,
|
|
30
|
+
IncidentState,
|
|
31
|
+
TERMINAL_STATES,
|
|
32
|
+
)
|
|
33
|
+
from nthlayer_workers.respond.worker_helpers import (
|
|
34
|
+
breach_ids_with_snapshots,
|
|
35
|
+
filter_after,
|
|
36
|
+
open_from_breach,
|
|
37
|
+
open_from_snapshot,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
logger = structlog.get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Cursors:
|
|
45
|
+
"""Polling cursors for trigger ingestion. Persisted via component_state."""
|
|
46
|
+
snapshot_after: str | None = None
|
|
47
|
+
breach_after: str | None = None
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return {
|
|
51
|
+
"snapshot_after": self.snapshot_after,
|
|
52
|
+
"breach_after": self.breach_after,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: dict) -> Cursors:
|
|
57
|
+
return cls(
|
|
58
|
+
snapshot_after=data.get("snapshot_after"),
|
|
59
|
+
breach_after=data.get("breach_after"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_terminal_and_aged(context: IncidentContext, retention_seconds: float) -> bool:
|
|
64
|
+
"""True if context is in a terminal state AND older than retention window.
|
|
65
|
+
|
|
66
|
+
Terminal states (RESOLVED/ESCALATED/FAILED) are kept for retention_seconds
|
|
67
|
+
so operators can inspect closed incidents; older terminal incidents are
|
|
68
|
+
pruned on the next get_state() call.
|
|
69
|
+
"""
|
|
70
|
+
if context.state not in TERMINAL_STATES:
|
|
71
|
+
return False
|
|
72
|
+
try:
|
|
73
|
+
# updated_at is ISO 8601 (set by coordinator and ingest paths)
|
|
74
|
+
updated = datetime.fromisoformat(context.updated_at.replace("Z", "+00:00"))
|
|
75
|
+
if updated.tzinfo is None:
|
|
76
|
+
# Naive datetime — treat as UTC for the subtraction. Without this
|
|
77
|
+
# the next line raises TypeError ("can't subtract offset-naive
|
|
78
|
+
# and offset-aware datetimes"), which would block every save.
|
|
79
|
+
updated = updated.replace(tzinfo=timezone.utc)
|
|
80
|
+
age = (datetime.now(timezone.utc) - updated).total_seconds()
|
|
81
|
+
except (ValueError, AttributeError, TypeError) as exc:
|
|
82
|
+
# Malformed timestamp → treat as not-aged so we don't drop accidentally.
|
|
83
|
+
# Debug-level so it's invisible until needed for diagnosis.
|
|
84
|
+
logger.debug(
|
|
85
|
+
"respond_terminal_age_check_skipped_malformed_timestamp",
|
|
86
|
+
updated_at=context.updated_at,
|
|
87
|
+
error=str(exc),
|
|
88
|
+
)
|
|
89
|
+
return False
|
|
90
|
+
return age >= retention_seconds
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _NoopContextStore:
|
|
94
|
+
"""Satisfies the coordinator's context_store interface without persisting.
|
|
95
|
+
|
|
96
|
+
Worker mode owns persistence at the cycle boundary, not per-step. The
|
|
97
|
+
coordinator's existing save(context) calls become no-ops here. Reads
|
|
98
|
+
are NEVER expected in worker mode — the worker holds the live
|
|
99
|
+
IncidentContext in self._incidents — so load() and list_active() raise
|
|
100
|
+
loudly rather than returning silent-wrong-answer values like None or [].
|
|
101
|
+
Loud failure on misuse beats invisible bugs.
|
|
102
|
+
"""
|
|
103
|
+
def save(self, context: IncidentContext) -> None:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def load(self, incident_id: str) -> IncidentContext | None:
|
|
107
|
+
raise NotImplementedError(
|
|
108
|
+
"worker mode reads from RespondModule._incidents directly, not context_store"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def list_active(self) -> list[str]:
|
|
112
|
+
raise NotImplementedError(
|
|
113
|
+
"worker mode reads from RespondModule._incidents directly, not context_store"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def list_all(self, limit: int = 50) -> list[IncidentContext]:
|
|
117
|
+
raise NotImplementedError(
|
|
118
|
+
"worker mode reads from RespondModule._incidents directly, not context_store"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def get_metadata(self, key: str) -> str | None:
|
|
122
|
+
raise NotImplementedError("worker mode does not use context_store metadata")
|
|
123
|
+
|
|
124
|
+
def set_metadata(self, key: str, value: str) -> None:
|
|
125
|
+
raise NotImplementedError("worker mode does not use context_store metadata")
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class RespondModule:
|
|
133
|
+
"""Respond worker module — drives incidents from triggers polled from core API.
|
|
134
|
+
|
|
135
|
+
Reads correlation_snapshot assessments (primary) and quality_breach verdicts
|
|
136
|
+
(fallback) from core, opens incident contexts, runs the agent pipeline,
|
|
137
|
+
submits verdicts back to core, persists incident state to component_state.
|
|
138
|
+
|
|
139
|
+
**Single-instance contract (v1.5):** RespondModule does NOT acquire a lease
|
|
140
|
+
on triggers or incidents. Two RespondModule instances polling the same core
|
|
141
|
+
will both ingest the same snapshots/breaches and open duplicate incidents
|
|
142
|
+
(different INC-... ids, identical trigger_verdict_ids). v1.5 deployments
|
|
143
|
+
must run exactly one workers process per core. Multi-instance HA is a v2
|
|
144
|
+
concern (see spec "Out of P3-E.1 scope" — case-API integration covers
|
|
145
|
+
lease-based ownership for incidents).
|
|
146
|
+
"""
|
|
147
|
+
client: CoreAPIClient
|
|
148
|
+
config: RespondConfig
|
|
149
|
+
deployment_id: str | None = None
|
|
150
|
+
|
|
151
|
+
# In-memory mirrors of component_state, restored on restore_state()
|
|
152
|
+
_cursors: Cursors = field(default_factory=Cursors)
|
|
153
|
+
_incidents: dict[str, IncidentContext] = field(default_factory=dict)
|
|
154
|
+
|
|
155
|
+
# Lazy: agents and coordinator constructed on first drive cycle. Tests
|
|
156
|
+
# may pre-set these via _agents/_coordinator to inject mocks.
|
|
157
|
+
_agents: dict | None = None
|
|
158
|
+
_coordinator: Coordinator | None = None
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def name(self) -> str:
|
|
162
|
+
# Single-module form for v1.5. If the spec's planned split into
|
|
163
|
+
# RespondTriggerModule (fast trigger detection) and RespondPipelineModule
|
|
164
|
+
# (slow pipeline drive) is implemented, rename to "respond.pipeline" to
|
|
165
|
+
# match the observe.{collect,drift,topology} / correlate.{session,topology,
|
|
166
|
+
# contract} naming convention used elsewhere in workers.
|
|
167
|
+
return "respond"
|
|
168
|
+
|
|
169
|
+
async def restore_state(self, state: dict | None) -> None:
|
|
170
|
+
"""Reload cursors and active incidents from component_state."""
|
|
171
|
+
if state is None:
|
|
172
|
+
return
|
|
173
|
+
self._cursors = Cursors.from_dict(state.get("cursors") or {})
|
|
174
|
+
for incident_id, raw in (state.get("incidents") or {}).items():
|
|
175
|
+
try:
|
|
176
|
+
self._incidents[incident_id] = incident_context_from_dict(raw)
|
|
177
|
+
except (KeyError, ValueError, TypeError):
|
|
178
|
+
logger.warning(
|
|
179
|
+
"respond_state_restore_skipped_corrupt_incident",
|
|
180
|
+
incident_id=incident_id,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def process_cycle(self) -> None:
|
|
184
|
+
"""One cycle: detect new triggers → drive active incidents.
|
|
185
|
+
|
|
186
|
+
State is persisted by the runner via get_state() after this returns.
|
|
187
|
+
"""
|
|
188
|
+
await self._ingest_triggers()
|
|
189
|
+
await self._drive_active_incidents()
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------ #
|
|
192
|
+
# Pipeline drive #
|
|
193
|
+
# ------------------------------------------------------------------ #
|
|
194
|
+
|
|
195
|
+
def _ensure_coordinator(self) -> Coordinator:
|
|
196
|
+
"""Lazily construct agents + coordinator on first drive cycle."""
|
|
197
|
+
if self._coordinator is not None:
|
|
198
|
+
return self._coordinator
|
|
199
|
+
|
|
200
|
+
if self._agents is None:
|
|
201
|
+
self._agents = self._build_agents()
|
|
202
|
+
|
|
203
|
+
self._coordinator = Coordinator(
|
|
204
|
+
agents=self._agents,
|
|
205
|
+
context_store=_NoopContextStore(),
|
|
206
|
+
verdict_store=None, # worker mode: agents submit via CoreAPIClient
|
|
207
|
+
config=self.config,
|
|
208
|
+
safe_action_registry=None, # P3-E.3
|
|
209
|
+
escalation_runner=None, # P3-E.5
|
|
210
|
+
)
|
|
211
|
+
return self._coordinator
|
|
212
|
+
|
|
213
|
+
def _build_agents(self) -> dict:
|
|
214
|
+
"""Construct the agent stack with worker-mode (client-based) plumbing."""
|
|
215
|
+
# Local imports to avoid pulling agent modules at import time of
|
|
216
|
+
# respond.worker (keeps test collection fast and avoids prompt-file
|
|
217
|
+
# FileNotFoundError on environments where prompts aren't bundled).
|
|
218
|
+
from nthlayer_workers.respond.agents.communication import CommunicationAgent
|
|
219
|
+
from nthlayer_workers.respond.agents.investigation import InvestigationAgent
|
|
220
|
+
from nthlayer_workers.respond.agents.remediation import RemediationAgent
|
|
221
|
+
from nthlayer_workers.respond.agents.triage import TriageAgent
|
|
222
|
+
from nthlayer_workers.respond.types import AgentRole
|
|
223
|
+
|
|
224
|
+
agent_config = {
|
|
225
|
+
"root_cause_threshold": self.config.root_cause_threshold,
|
|
226
|
+
"arbiter_url": self.config.arbiter_url,
|
|
227
|
+
"escalation_threshold": self.config.escalation_threshold,
|
|
228
|
+
}
|
|
229
|
+
kwargs = {
|
|
230
|
+
"client": self.client,
|
|
231
|
+
"deployment_id": self.deployment_id,
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
AgentRole.TRIAGE: TriageAgent(
|
|
235
|
+
self.config.model, self.config.max_tokens, None, agent_config,
|
|
236
|
+
timeout=self.config.triage_timeout, **kwargs,
|
|
237
|
+
),
|
|
238
|
+
AgentRole.INVESTIGATION: InvestigationAgent(
|
|
239
|
+
self.config.model, self.config.max_tokens, None, agent_config,
|
|
240
|
+
timeout=self.config.investigation_timeout, **kwargs,
|
|
241
|
+
),
|
|
242
|
+
AgentRole.COMMUNICATION: CommunicationAgent(
|
|
243
|
+
self.config.model, self.config.max_tokens, None, agent_config,
|
|
244
|
+
timeout=self.config.communication_timeout, **kwargs,
|
|
245
|
+
),
|
|
246
|
+
AgentRole.REMEDIATION: RemediationAgent(
|
|
247
|
+
self.config.model, self.config.max_tokens, None, agent_config,
|
|
248
|
+
timeout=self.config.remediation_timeout,
|
|
249
|
+
safe_action_registry=None, # P3-E.3 wires the registry
|
|
250
|
+
**kwargs,
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async def _drive_active_incidents(self) -> None:
|
|
255
|
+
"""Run each non-terminal, non-AWAITING_APPROVAL incident through the
|
|
256
|
+
coordinator pipeline.
|
|
257
|
+
|
|
258
|
+
AWAITING_APPROVAL incidents are skipped — coordinator's _run_pipeline
|
|
259
|
+
also early-returns on that state, but skipping here avoids the call
|
|
260
|
+
entirely. Resumption (poll for approval verdict) is P3-E.3.
|
|
261
|
+
|
|
262
|
+
Incidents in TERMINAL_STATES are skipped (they only need terminal
|
|
263
|
+
retention pruning, handled in get_state()).
|
|
264
|
+
"""
|
|
265
|
+
# Snapshot the incident IDs to avoid mutation-during-iteration if
|
|
266
|
+
# _ingest_triggers were ever extended to run concurrently with this.
|
|
267
|
+
# Today the cycle is sequential, but the snapshot makes the invariant
|
|
268
|
+
# explicit.
|
|
269
|
+
incident_ids = list(self._incidents.keys())
|
|
270
|
+
if not incident_ids:
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
coordinator = self._ensure_coordinator()
|
|
274
|
+
for incident_id in incident_ids:
|
|
275
|
+
context = self._incidents[incident_id]
|
|
276
|
+
if context.state in TERMINAL_STATES:
|
|
277
|
+
continue
|
|
278
|
+
if context.state == IncidentState.AWAITING_APPROVAL:
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
updated = await coordinator.run(context)
|
|
282
|
+
self._incidents[incident_id] = updated
|
|
283
|
+
except Exception: # noqa: BLE001
|
|
284
|
+
logger.exception(
|
|
285
|
+
"respond_incident_drive_failed",
|
|
286
|
+
incident_id=incident_id,
|
|
287
|
+
)
|
|
288
|
+
# coordinator.run already catches inner exceptions and sets
|
|
289
|
+
# FAILED. This catch is for unexpected escapes (e.g. errors
|
|
290
|
+
# during coordinator construction). Mark FAILED so the
|
|
291
|
+
# incident doesn't get retried indefinitely.
|
|
292
|
+
context.state = IncidentState.FAILED
|
|
293
|
+
context.error = "unrecoverable: coordinator.run escape"
|
|
294
|
+
self._incidents[incident_id] = context
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------------ #
|
|
297
|
+
# Trigger ingestion #
|
|
298
|
+
# ------------------------------------------------------------------ #
|
|
299
|
+
|
|
300
|
+
async def _ingest_triggers(self) -> None:
|
|
301
|
+
"""Detect new triggers and open incidents.
|
|
302
|
+
|
|
303
|
+
Primary path: correlation_snapshot assessments (situation-shaped).
|
|
304
|
+
Fallback path: quality_breach verdicts older than fallback_threshold
|
|
305
|
+
without an associated correlation_snapshot referencing them.
|
|
306
|
+
|
|
307
|
+
Snapshots are fetched once and reused for the fallback dedup check —
|
|
308
|
+
avoids the per-breach API fan-out that would otherwise occur.
|
|
309
|
+
"""
|
|
310
|
+
snapshot_cache = await self._ingest_snapshots()
|
|
311
|
+
await self._ingest_orphan_breaches(snapshot_cache)
|
|
312
|
+
|
|
313
|
+
async def _ingest_snapshots(self) -> list[dict]:
|
|
314
|
+
"""Primary trigger path: correlation_snapshot assessments.
|
|
315
|
+
|
|
316
|
+
Returns the snapshot page (already filtered by cursor) so the fallback
|
|
317
|
+
path can dedup against it without re-fetching.
|
|
318
|
+
"""
|
|
319
|
+
result = await self.client.get_assessments(kind="correlation_snapshot")
|
|
320
|
+
if not result.ok:
|
|
321
|
+
logger.warning(
|
|
322
|
+
"respond_snapshot_poll_failed",
|
|
323
|
+
status_code=result.status_code,
|
|
324
|
+
error=result.error,
|
|
325
|
+
)
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
all_snapshots = result.data or []
|
|
329
|
+
# Client-side filter by created_after the cursor. v1.5 limitation:
|
|
330
|
+
# core's /assessments API filters by kind only. Mirrors P3-D.1's same
|
|
331
|
+
# constraint in correlate.
|
|
332
|
+
snapshots = filter_after(all_snapshots, self._cursors.snapshot_after)
|
|
333
|
+
if not snapshots:
|
|
334
|
+
return all_snapshots
|
|
335
|
+
|
|
336
|
+
# Sort by created_at ascending so cursor advances monotonically.
|
|
337
|
+
snapshots.sort(key=lambda s: s.get("created_at", ""))
|
|
338
|
+
triggered = self._incidents_triggered_from()
|
|
339
|
+
for snap in snapshots:
|
|
340
|
+
snap_id = snap.get("id")
|
|
341
|
+
created_at = snap.get("created_at")
|
|
342
|
+
if not snap_id or not created_at:
|
|
343
|
+
continue
|
|
344
|
+
if snap_id not in triggered:
|
|
345
|
+
try:
|
|
346
|
+
await self._open_incident_from_snapshot(snap)
|
|
347
|
+
except Exception: # noqa: BLE001
|
|
348
|
+
# Defensive: malformed snapshot must not stall the cursor
|
|
349
|
+
# forever (would block all subsequent snapshots in the
|
|
350
|
+
# batch and on every later cycle).
|
|
351
|
+
logger.exception(
|
|
352
|
+
"respond_snapshot_open_failed",
|
|
353
|
+
snap_id=snap_id,
|
|
354
|
+
)
|
|
355
|
+
# Advance cursor whether opened, skipped (already triggered), or
|
|
356
|
+
# failed (logged). Asymmetry-fix: only advance after the open
|
|
357
|
+
# attempt, not before — matches the spec's restartable-on-failure
|
|
358
|
+
# intent for the snapshot path.
|
|
359
|
+
self._cursors.snapshot_after = created_at
|
|
360
|
+
|
|
361
|
+
return all_snapshots
|
|
362
|
+
|
|
363
|
+
async def _ingest_orphan_breaches(self, snapshot_cache: list[dict]) -> None:
|
|
364
|
+
"""Fallback trigger path: quality_breach verdicts without snapshot.
|
|
365
|
+
|
|
366
|
+
For each new quality_breach verdict older than fallback_threshold_seconds:
|
|
367
|
+
check whether a correlation_snapshot exists referencing it. If yes, skip
|
|
368
|
+
(already represented). If no, open an incident from the breach directly.
|
|
369
|
+
|
|
370
|
+
``snapshot_cache`` is the per-cycle cache from _ingest_snapshots — used
|
|
371
|
+
for parent_ids dedup without re-fetching once per breach. v1.5 limitation:
|
|
372
|
+
if the snapshot list paginates beyond the page limit, snapshots beyond
|
|
373
|
+
page 1 may be missed (logged as v2 follow-up: server-side parent_ids
|
|
374
|
+
filter).
|
|
375
|
+
|
|
376
|
+
Logs the threshold value separately so the metadata.fallback_reason
|
|
377
|
+
string stays stable if the threshold becomes operationally configurable.
|
|
378
|
+
"""
|
|
379
|
+
cutoff = datetime.now(timezone.utc) - timedelta(
|
|
380
|
+
seconds=self.config.fallback_threshold_seconds
|
|
381
|
+
)
|
|
382
|
+
cutoff_iso = cutoff.isoformat()
|
|
383
|
+
# Guard against created_after > created_before, which is undefined at
|
|
384
|
+
# the core API level. Can happen with clock skew, a stalled worker
|
|
385
|
+
# whose cursor advanced past now-cutoff, or a manual cursor injection.
|
|
386
|
+
if self._cursors.breach_after and self._cursors.breach_after >= cutoff_iso:
|
|
387
|
+
return
|
|
388
|
+
result = await self.client.get_verdicts(
|
|
389
|
+
verdict_type="quality_breach",
|
|
390
|
+
created_before=cutoff_iso,
|
|
391
|
+
created_after=self._cursors.breach_after,
|
|
392
|
+
)
|
|
393
|
+
if not result.ok:
|
|
394
|
+
logger.warning(
|
|
395
|
+
"respond_breach_poll_failed",
|
|
396
|
+
status_code=result.status_code,
|
|
397
|
+
error=result.error,
|
|
398
|
+
)
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
breaches = result.data or []
|
|
402
|
+
if not breaches:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# Build the dedup set once from the snapshot cache, not per-breach.
|
|
406
|
+
snapshotted_breach_ids = breach_ids_with_snapshots(snapshot_cache)
|
|
407
|
+
|
|
408
|
+
breaches.sort(key=lambda b: b.get("created_at", ""))
|
|
409
|
+
triggered = self._incidents_triggered_from()
|
|
410
|
+
for breach in breaches:
|
|
411
|
+
breach_id = breach.get("id")
|
|
412
|
+
created_at = breach.get("created_at")
|
|
413
|
+
if not breach_id or not created_at:
|
|
414
|
+
continue
|
|
415
|
+
try:
|
|
416
|
+
if breach_id in triggered:
|
|
417
|
+
pass # already opened in a prior cycle
|
|
418
|
+
elif breach_id in snapshotted_breach_ids:
|
|
419
|
+
pass # represented by a snapshot incident — fallback skipped
|
|
420
|
+
else:
|
|
421
|
+
opened_id = await self._open_incident_from_breach(breach)
|
|
422
|
+
logger.info(
|
|
423
|
+
"respond_fallback_trigger_used",
|
|
424
|
+
incident_id=opened_id,
|
|
425
|
+
breach_id=breach_id,
|
|
426
|
+
fallback_threshold_seconds=self.config.fallback_threshold_seconds,
|
|
427
|
+
)
|
|
428
|
+
except Exception: # noqa: BLE001
|
|
429
|
+
logger.exception(
|
|
430
|
+
"respond_breach_open_failed",
|
|
431
|
+
breach_id=breach_id,
|
|
432
|
+
)
|
|
433
|
+
# Advance cursor after every breach (opened, skipped, or failed)
|
|
434
|
+
# so a single bad record doesn't permanently stall the cursor.
|
|
435
|
+
self._cursors.breach_after = created_at
|
|
436
|
+
|
|
437
|
+
def _incidents_triggered_from(self) -> set[str]:
|
|
438
|
+
"""Union of trigger_verdict_ids across all active incidents.
|
|
439
|
+
|
|
440
|
+
Used to dedup snapshots and breaches if cursor advancement hiccups
|
|
441
|
+
(e.g. process restart with stale cursor). A trigger we've already
|
|
442
|
+
opened an incident for must not produce another incident.
|
|
443
|
+
"""
|
|
444
|
+
seen: set[str] = set()
|
|
445
|
+
for ctx in self._incidents.values():
|
|
446
|
+
seen.update(ctx.trigger_verdict_ids)
|
|
447
|
+
return seen
|
|
448
|
+
|
|
449
|
+
# ------------------------------------------------------------------ #
|
|
450
|
+
# Incident opening #
|
|
451
|
+
# ------------------------------------------------------------------ #
|
|
452
|
+
|
|
453
|
+
async def _open_incident_from_snapshot(self, snap: dict) -> str:
|
|
454
|
+
"""Open an incident from a correlation_snapshot assessment.
|
|
455
|
+
|
|
456
|
+
Resolves real service tiers from manifests via the core API so the
|
|
457
|
+
triage agent's severity derivation is grounded in declared tier rather
|
|
458
|
+
than a hardcoded placeholder.
|
|
459
|
+
"""
|
|
460
|
+
affected = (snap.get("data") or {}).get("affected_services") or [
|
|
461
|
+
(snap.get("data") or {}).get("domain", {}).get("service") or "unknown"
|
|
462
|
+
]
|
|
463
|
+
tiers = await self._resolve_tiers(affected)
|
|
464
|
+
ctx = open_from_snapshot(snap, self.config, tiers=tiers)
|
|
465
|
+
self._incidents[ctx.id] = ctx
|
|
466
|
+
return ctx.id
|
|
467
|
+
|
|
468
|
+
async def _open_incident_from_breach(self, breach: dict) -> str:
|
|
469
|
+
"""Open an incident from a quality_breach verdict (fallback path)."""
|
|
470
|
+
service = (
|
|
471
|
+
breach.get("service")
|
|
472
|
+
or (breach.get("subject") or {}).get("service")
|
|
473
|
+
or "unknown"
|
|
474
|
+
)
|
|
475
|
+
tiers = await self._resolve_tiers([service])
|
|
476
|
+
ctx = open_from_breach(breach, self.config, tiers=tiers)
|
|
477
|
+
self._incidents[ctx.id] = ctx
|
|
478
|
+
return ctx.id
|
|
479
|
+
|
|
480
|
+
async def _resolve_tiers(self, services: list[str]) -> dict[str, str]:
|
|
481
|
+
"""Fetch tiers for the given services from core's manifest catalogue.
|
|
482
|
+
|
|
483
|
+
Returns a service -> tier mapping. Services with no manifest, manifest
|
|
484
|
+
fetch failures, or transport-level exceptions all fall back to
|
|
485
|
+
``"standard"`` so triage still receives a reasonable default — a
|
|
486
|
+
manifest-fetch failure must not prevent an incident from being opened
|
|
487
|
+
(drop-the-trigger would be a worse outcome than degrade-to-standard).
|
|
488
|
+
Failures are logged so operators can spot manifest registration gaps.
|
|
489
|
+
"""
|
|
490
|
+
tiers: dict[str, str] = {}
|
|
491
|
+
for svc in services:
|
|
492
|
+
if not svc or svc in tiers:
|
|
493
|
+
continue
|
|
494
|
+
try:
|
|
495
|
+
result = await self.client.get_manifest(svc)
|
|
496
|
+
except Exception: # noqa: BLE001
|
|
497
|
+
# CoreAPIClient is designed to never raise, but we defensively
|
|
498
|
+
# degrade rather than propagate — a network blip during tier
|
|
499
|
+
# lookup must not drop the incident.
|
|
500
|
+
logger.warning(
|
|
501
|
+
"respond_manifest_lookup_exception_fallback_to_standard",
|
|
502
|
+
service=svc,
|
|
503
|
+
exc_info=True,
|
|
504
|
+
)
|
|
505
|
+
tiers[svc] = "standard"
|
|
506
|
+
continue
|
|
507
|
+
if result.ok and isinstance(result.data, dict):
|
|
508
|
+
tiers[svc] = result.data.get("tier", "standard") or "standard"
|
|
509
|
+
else:
|
|
510
|
+
logger.info(
|
|
511
|
+
"respond_manifest_lookup_fallback_to_standard",
|
|
512
|
+
service=svc,
|
|
513
|
+
status_code=getattr(result, "status_code", None),
|
|
514
|
+
)
|
|
515
|
+
tiers[svc] = "standard"
|
|
516
|
+
return tiers
|
|
517
|
+
|
|
518
|
+
async def get_state(self) -> dict:
|
|
519
|
+
"""Return cursors + active incidents (with terminal-state pruning).
|
|
520
|
+
|
|
521
|
+
Pruning rule: incidents in TERMINAL_STATES whose updated_at is older
|
|
522
|
+
than config.terminal_retention_seconds are dropped. Active incidents
|
|
523
|
+
are never pruned regardless of age.
|
|
524
|
+
"""
|
|
525
|
+
retention = self.config.terminal_retention_seconds
|
|
526
|
+
return {
|
|
527
|
+
"cursors": self._cursors.to_dict(),
|
|
528
|
+
"incidents": {
|
|
529
|
+
inc_id: incident_context_to_dict(ctx)
|
|
530
|
+
for inc_id, ctx in self._incidents.items()
|
|
531
|
+
if not _is_terminal_and_aged(ctx, retention)
|
|
532
|
+
},
|
|
533
|
+
}
|