feat(scouts): deliver_pending drains queue and sends scout_proposal frames
Add ScoutEngine.deliver_pending(user_id, ws) that queries status='queued' rows, fetches metadata via the registered connector, sends scout_proposal WS frames, and flips status to 'delivered'. Add ack_proposal(proposal_id) that flips 'delivered' -> 'acked' (idempotent). Wire both into device_ws.py: deliver_pending fires as a background task after device_hello + register; scout_proposal_ack frames dispatch to ack_proposal in the message loop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -170,3 +170,48 @@ async def test_idempotent_replay(monkeypatch):
|
||||
async with _TestSessionLocal() as session:
|
||||
rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
|
||||
assert len(rows) == 1, "Replay must not create duplicate queue rows"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_pending_sends_one_frame_per_queued_row(monkeypatch):
|
||||
user_id = "00000000-0000-0000-0000-000000000003"
|
||||
scout_id = str(uuid.uuid4())
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
async with _TestSessionLocal() as session:
|
||||
session.add(CloudScoutConfig(
|
||||
id=scout_id, user_id=user_id, provider="gmail", name="Test",
|
||||
data_types=[], prompt_template="", schedule_cron="0 * * * *",
|
||||
enabled=True, auto_trash_spam=False, device_inactivity_pause_days=14,
|
||||
))
|
||||
for i in range(3):
|
||||
session.add(ScoutTriageQueue(
|
||||
id=str(uuid.uuid4()), user_id=user_id, scout_id=scout_id,
|
||||
source_type="gmail", source_msg_ref=f"msg-{i}",
|
||||
triage_verdict="relevant", status="queued",
|
||||
triaged_at=now, expires_at=now + timedelta(days=30),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
connector = AsyncMock()
|
||||
connector.source_type = "gmail"
|
||||
connector.fetch_metadata = AsyncMock(side_effect=lambda scout, ref: ItemMetadata(
|
||||
subject=f"sub-{ref.source_msg_ref}", snippet=f"snip-{ref.source_msg_ref}",
|
||||
))
|
||||
register_connector(connector)
|
||||
|
||||
sent = []
|
||||
ws = AsyncMock()
|
||||
ws.send_json = AsyncMock(side_effect=lambda payload: sent.append(payload))
|
||||
|
||||
engine = ScoutEngine(session_factory=_TestSessionLocal)
|
||||
await engine.deliver_pending(uuid.UUID(user_id), ws)
|
||||
|
||||
assert len(sent) == 3
|
||||
assert all(s["type"] == "scout_proposal" for s in sent)
|
||||
subjects = {s["proposal"]["raw_subject"] for s in sent}
|
||||
assert subjects == {"sub-msg-0", "sub-msg-1", "sub-msg-2"}
|
||||
async with _TestSessionLocal() as session:
|
||||
rows = (await session.execute(select(ScoutTriageQueue))).scalars().all()
|
||||
assert all(r.status == "delivered" for r in rows)
|
||||
assert all(r.delivered_at is not None for r in rows)
|
||||
|
||||
Reference in New Issue
Block a user