fix(incidents): bind durable readback tenant context
This commit is contained in:
@@ -155,6 +155,12 @@ class IncidentTimelineResponse(BaseModel):
|
|||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
async def list_incidents(
|
async def list_incidents(
|
||||||
|
project_id: str = Query(
|
||||||
|
"awoooi",
|
||||||
|
min_length=1,
|
||||||
|
max_length=64,
|
||||||
|
description="租戶 ID;legacy incident surface 預設綁定 awoooi。",
|
||||||
|
),
|
||||||
generate_missing_decisions: bool = Query(
|
generate_missing_decisions: bool = Query(
|
||||||
False,
|
False,
|
||||||
description=(
|
description=(
|
||||||
@@ -180,7 +186,9 @@ async def list_incidents(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 透過 Service 取得活躍事件
|
# 透過 Service 取得活躍事件
|
||||||
incidents = await incident_service.get_active_incidents()
|
incidents = await incident_service.get_active_incidents(
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
|
||||||
# 按時間排序 (最新優先)
|
# 按時間排序 (最新優先)
|
||||||
# 2026-03-26 修復: 處理 timezone-aware 與 naive datetime 混合問題
|
# 2026-03-26 修復: 處理 timezone-aware 與 naive datetime 混合問題
|
||||||
@@ -289,7 +297,15 @@ async def list_incidents(
|
|||||||
summary="取得單一事件",
|
summary="取得單一事件",
|
||||||
description="取得特定事件的詳細資訊。",
|
description="取得特定事件的詳細資訊。",
|
||||||
)
|
)
|
||||||
async def get_incident(incident_id: str) -> IncidentResponse:
|
async def get_incident(
|
||||||
|
incident_id: str,
|
||||||
|
project_id: str = Query(
|
||||||
|
"awoooi",
|
||||||
|
min_length=1,
|
||||||
|
max_length=64,
|
||||||
|
description="租戶 ID;legacy incident surface 預設綁定 awoooi。",
|
||||||
|
),
|
||||||
|
) -> IncidentResponse:
|
||||||
"""
|
"""
|
||||||
取得單一事件
|
取得單一事件
|
||||||
|
|
||||||
@@ -306,7 +322,10 @@ async def get_incident(incident_id: str) -> IncidentResponse:
|
|||||||
incident_service = get_incident_service()
|
incident_service = get_incident_service()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
incident = await incident_service.get_for_readback(incident_id)
|
incident = await incident_service.get_for_readback(
|
||||||
|
incident_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
if incident is None:
|
if incident is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -168,13 +168,13 @@ class IncidentDBRepository(IIncidentRepository):
|
|||||||
record = result.scalar_one_or_none()
|
record = result.scalar_one_or_none()
|
||||||
return _record_to_incident(record) if record else None
|
return _record_to_incident(record) if record else None
|
||||||
|
|
||||||
async def get_active(self) -> list[Incident]:
|
async def get_active(self, *, project_id: str | None = None) -> list[Incident]:
|
||||||
"""取得所有活躍的 Incident"""
|
"""取得所有活躍的 Incident"""
|
||||||
active_statuses = [
|
active_statuses = [
|
||||||
IncidentStatus.INVESTIGATING,
|
IncidentStatus.INVESTIGATING,
|
||||||
IncidentStatus.MITIGATING,
|
IncidentStatus.MITIGATING,
|
||||||
]
|
]
|
||||||
async with get_db_context() as db:
|
async with get_db_context(project_id) as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(IncidentRecord)
|
select(IncidentRecord)
|
||||||
.where(IncidentRecord.status.in_(active_statuses))
|
.where(IncidentRecord.status.in_(active_statuses))
|
||||||
|
|||||||
@@ -125,7 +125,11 @@ class IIncidentRepository(Protocol):
|
|||||||
"""根據 ID 取得 Incident"""
|
"""根據 ID 取得 Incident"""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def get_active(self) -> list[Incident]:
|
async def get_active(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> list[Incident]:
|
||||||
"""取得所有活躍的 Incident"""
|
"""取得所有活躍的 Incident"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
@@ -730,9 +730,17 @@ class IncidentService:
|
|||||||
)
|
)
|
||||||
return "updated"
|
return "updated"
|
||||||
|
|
||||||
async def get_for_readback(self, incident_id: str) -> Incident | None:
|
async def get_for_readback(
|
||||||
|
self,
|
||||||
|
incident_id: str,
|
||||||
|
*,
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> Incident | None:
|
||||||
"""Return durable incident truth, with Redis as an availability fallback."""
|
"""Return durable incident truth, with Redis as an availability fallback."""
|
||||||
incident = await self.get_from_episodic_memory(incident_id)
|
incident = await self.get_from_episodic_memory(
|
||||||
|
incident_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
if incident is not None:
|
if incident is not None:
|
||||||
return incident
|
return incident
|
||||||
|
|
||||||
@@ -742,7 +750,11 @@ class IncidentService:
|
|||||||
)
|
)
|
||||||
return await self.get_from_working_memory(incident_id)
|
return await self.get_from_working_memory(incident_id)
|
||||||
|
|
||||||
async def get_active_incidents(self) -> list[Incident]:
|
async def get_active_incidents(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> list[Incident]:
|
||||||
"""
|
"""
|
||||||
列出所有活躍的 Incidents。
|
列出所有活躍的 Incidents。
|
||||||
|
|
||||||
@@ -755,7 +767,9 @@ class IncidentService:
|
|||||||
try:
|
try:
|
||||||
from src.repositories.incident_repository import get_incident_repository
|
from src.repositories.incident_repository import get_incident_repository
|
||||||
|
|
||||||
incidents = await get_incident_repository().get_active()
|
incidents = await get_incident_repository().get_active(
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"get_active_incidents_from_episodic_memory",
|
"get_active_incidents_from_episodic_memory",
|
||||||
count=len(incidents),
|
count=len(incidents),
|
||||||
|
|||||||
@@ -37,11 +37,18 @@ async def test_readback_prefers_durable_resolved_incident_over_stale_cache(monke
|
|||||||
working_read = AsyncMock(return_value=cached)
|
working_read = AsyncMock(return_value=cached)
|
||||||
monkeypatch.setattr(service, "get_from_working_memory", working_read)
|
monkeypatch.setattr(service, "get_from_working_memory", working_read)
|
||||||
|
|
||||||
result = await service.get_for_readback(durable.incident_id)
|
result = await service.get_for_readback(
|
||||||
|
durable.incident_id,
|
||||||
|
project_id="awoooi",
|
||||||
|
)
|
||||||
|
|
||||||
assert result is durable
|
assert result is durable
|
||||||
assert result.status is IncidentStatus.RESOLVED
|
assert result.status is IncidentStatus.RESOLVED
|
||||||
working_read.assert_not_awaited()
|
working_read.assert_not_awaited()
|
||||||
|
service.get_from_episodic_memory.assert_awaited_once_with(
|
||||||
|
durable.incident_id,
|
||||||
|
project_id="awoooi",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -61,7 +68,10 @@ async def test_readback_falls_back_to_working_memory_when_durable_record_unavail
|
|||||||
AsyncMock(return_value=cached),
|
AsyncMock(return_value=cached),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await service.get_for_readback(cached.incident_id)
|
result = await service.get_for_readback(
|
||||||
|
cached.incident_id,
|
||||||
|
project_id="awoooi",
|
||||||
|
)
|
||||||
|
|
||||||
assert result is cached
|
assert result is cached
|
||||||
|
|
||||||
@@ -76,11 +86,17 @@ async def test_incident_detail_endpoint_uses_durable_readback(monkeypatch):
|
|||||||
service = _Service()
|
service = _Service()
|
||||||
monkeypatch.setattr(incidents_api, "get_incident_service", lambda: service)
|
monkeypatch.setattr(incidents_api, "get_incident_service", lambda: service)
|
||||||
|
|
||||||
result = await incidents_api.get_incident(durable.incident_id)
|
result = await incidents_api.get_incident(
|
||||||
|
durable.incident_id,
|
||||||
|
project_id="awoooi",
|
||||||
|
)
|
||||||
|
|
||||||
assert result.incident_id == durable.incident_id
|
assert result.incident_id == durable.incident_id
|
||||||
assert result.status == IncidentStatus.RESOLVED.value
|
assert result.status == IncidentStatus.RESOLVED.value
|
||||||
service.get_for_readback.assert_awaited_once_with(durable.incident_id)
|
service.get_for_readback.assert_awaited_once_with(
|
||||||
|
durable.incident_id,
|
||||||
|
project_id="awoooi",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -98,7 +114,7 @@ async def test_active_incidents_come_from_durable_repository(monkeypatch):
|
|||||||
lambda: repository,
|
lambda: repository,
|
||||||
)
|
)
|
||||||
|
|
||||||
incidents = await service.get_active_incidents()
|
incidents = await service.get_active_incidents(project_id="awoooi")
|
||||||
|
|
||||||
assert incidents == [durable_active]
|
assert incidents == [durable_active]
|
||||||
repository.get_active.assert_awaited_once_with()
|
repository.get_active.assert_awaited_once_with(project_id="awoooi")
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ from src.models.incident import Incident, Severity
|
|||||||
|
|
||||||
|
|
||||||
class _IncidentService:
|
class _IncidentService:
|
||||||
async def get_active_incidents(self) -> list[Incident]:
|
async def get_active_incidents(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> list[Incident]:
|
||||||
|
assert project_id == "awoooi"
|
||||||
return [
|
return [
|
||||||
Incident(
|
Incident(
|
||||||
incident_id="INC-20260506-PURE01",
|
incident_id="INC-20260506-PURE01",
|
||||||
@@ -52,7 +57,10 @@ async def test_list_incidents_does_not_trigger_ai_decision_by_default(monkeypatc
|
|||||||
monkeypatch.setattr(incidents_api, "get_incident_service", lambda: _IncidentService())
|
monkeypatch.setattr(incidents_api, "get_incident_service", lambda: _IncidentService())
|
||||||
monkeypatch.setattr(incidents_api, "get_decision_manager", lambda: decision_manager)
|
monkeypatch.setattr(incidents_api, "get_decision_manager", lambda: decision_manager)
|
||||||
|
|
||||||
result = await incidents_api.list_incidents(generate_missing_decisions=False)
|
result = await incidents_api.list_incidents(
|
||||||
|
project_id="awoooi",
|
||||||
|
generate_missing_decisions=False,
|
||||||
|
)
|
||||||
|
|
||||||
assert result.count == 1
|
assert result.count == 1
|
||||||
assert result.incidents[0].incident_id == "INC-20260506-PURE01"
|
assert result.incidents[0].incident_id == "INC-20260506-PURE01"
|
||||||
|
|||||||
Reference in New Issue
Block a user