fix(incidents): bind durable readback tenant context
This commit is contained in:
@@ -155,6 +155,12 @@ class IncidentTimelineResponse(BaseModel):
|
||||
""",
|
||||
)
|
||||
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(
|
||||
False,
|
||||
description=(
|
||||
@@ -180,7 +186,9 @@ async def list_incidents(
|
||||
|
||||
try:
|
||||
# 透過 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 混合問題
|
||||
@@ -289,7 +297,15 @@ async def list_incidents(
|
||||
summary="取得單一事件",
|
||||
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()
|
||||
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -168,13 +168,13 @@ class IncidentDBRepository(IIncidentRepository):
|
||||
record = result.scalar_one_or_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"""
|
||||
active_statuses = [
|
||||
IncidentStatus.INVESTIGATING,
|
||||
IncidentStatus.MITIGATING,
|
||||
]
|
||||
async with get_db_context() as db:
|
||||
async with get_db_context(project_id) as db:
|
||||
result = await db.execute(
|
||||
select(IncidentRecord)
|
||||
.where(IncidentRecord.status.in_(active_statuses))
|
||||
|
||||
@@ -125,7 +125,11 @@ class IIncidentRepository(Protocol):
|
||||
"""根據 ID 取得 Incident"""
|
||||
...
|
||||
|
||||
async def get_active(self) -> list[Incident]:
|
||||
async def get_active(
|
||||
self,
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
) -> list[Incident]:
|
||||
"""取得所有活躍的 Incident"""
|
||||
...
|
||||
|
||||
|
||||
@@ -730,9 +730,17 @@ class IncidentService:
|
||||
)
|
||||
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."""
|
||||
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:
|
||||
return incident
|
||||
|
||||
@@ -742,7 +750,11 @@ class IncidentService:
|
||||
)
|
||||
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。
|
||||
|
||||
@@ -755,7 +767,9 @@ class IncidentService:
|
||||
try:
|
||||
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(
|
||||
"get_active_incidents_from_episodic_memory",
|
||||
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)
|
||||
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.status is IncidentStatus.RESOLVED
|
||||
working_read.assert_not_awaited()
|
||||
service.get_from_episodic_memory.assert_awaited_once_with(
|
||||
durable.incident_id,
|
||||
project_id="awoooi",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -61,7 +68,10 @@ async def test_readback_falls_back_to_working_memory_when_durable_record_unavail
|
||||
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
|
||||
|
||||
@@ -76,11 +86,17 @@ async def test_incident_detail_endpoint_uses_durable_readback(monkeypatch):
|
||||
service = _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.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
|
||||
@@ -98,7 +114,7 @@ async def test_active_incidents_come_from_durable_repository(monkeypatch):
|
||||
lambda: repository,
|
||||
)
|
||||
|
||||
incidents = await service.get_active_incidents()
|
||||
incidents = await service.get_active_incidents(project_id="awoooi")
|
||||
|
||||
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:
|
||||
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 [
|
||||
Incident(
|
||||
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_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.incidents[0].incident_id == "INC-20260506-PURE01"
|
||||
|
||||
Reference in New Issue
Block a user