kairo-code 0.1.0__py3-none-any.whl → 0.2.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.
Files changed (50) hide show
  1. kairo/backend/api/agents.py +337 -16
  2. kairo/backend/app.py +84 -4
  3. kairo/backend/config.py +4 -2
  4. kairo/backend/models/agent.py +216 -2
  5. kairo/backend/models/api_key.py +4 -1
  6. kairo/backend/models/task.py +31 -0
  7. kairo/backend/models/user_provider_key.py +26 -0
  8. kairo/backend/schemas/agent.py +249 -2
  9. kairo/backend/schemas/api_key.py +3 -0
  10. kairo/backend/services/agent/__init__.py +52 -0
  11. kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
  12. kairo/backend/services/agent/agent_alerts_service.py +201 -0
  13. kairo/backend/services/agent/agent_commands_service.py +142 -0
  14. kairo/backend/services/agent/agent_crud_service.py +150 -0
  15. kairo/backend/services/agent/agent_events_service.py +103 -0
  16. kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
  17. kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
  18. kairo/backend/services/agent/agent_metrics_service.py +259 -0
  19. kairo/backend/services/agent/agent_service.py +315 -0
  20. kairo/backend/services/agent/agent_setup_service.py +180 -0
  21. kairo/backend/services/agent/constants.py +28 -0
  22. kairo/backend/services/agent_service.py +18 -102
  23. kairo/backend/services/api_key_service.py +23 -3
  24. kairo/backend/services/byok_service.py +204 -0
  25. kairo/backend/services/chat_service.py +398 -63
  26. kairo/backend/services/deep_search_service.py +159 -0
  27. kairo/backend/services/email_service.py +418 -19
  28. kairo/backend/services/few_shot_service.py +223 -0
  29. kairo/backend/services/post_processor.py +261 -0
  30. kairo/backend/services/rag_service.py +150 -0
  31. kairo/backend/services/task_service.py +119 -0
  32. kairo/backend/tests/__init__.py +1 -0
  33. kairo/backend/tests/e2e/__init__.py +1 -0
  34. kairo/backend/tests/e2e/agents/__init__.py +1 -0
  35. kairo/backend/tests/e2e/agents/conftest.py +389 -0
  36. kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
  37. kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
  38. kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
  39. kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
  40. kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
  41. kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
  42. kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
  43. kairo/migrations/versions/010_agent_dashboard.py +246 -0
  44. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
  45. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
  46. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
  47. kairo_migrations/env.py +92 -0
  48. kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
  49. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
  50. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,415 @@
1
+ """
2
+ End-to-end tests for Agent Events functionality.
3
+
4
+ Tests cover:
5
+ - Get events
6
+ - Filter by event type
7
+ - Event logging on actions
8
+ """
9
+
10
+ import pytest
11
+ import uuid
12
+ from datetime import datetime, timedelta, UTC
13
+ from httpx import AsyncClient
14
+ from sqlalchemy import select
15
+
16
+ from backend.models.agent import Agent, AgentEvent
17
+
18
+
19
+ class TestGetEvents:
20
+ """Tests for GET /api/agents/{agent_id}/events"""
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_get_events_empty(
24
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
25
+ ):
26
+ """Should return empty list for agent with no events."""
27
+ response = await client.get(
28
+ f"/api/agents/{test_agent.id}/events", headers=auth_headers
29
+ )
30
+
31
+ assert response.status_code == 200
32
+ data = response.json()
33
+ assert data == []
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_get_events_with_data(
37
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
38
+ ):
39
+ """Should return events for agent."""
40
+ # Create some events
41
+ await agent_factory.create_event(
42
+ test_agent.id, event_type="heartbeat", event_data={"status": "online"}
43
+ )
44
+ await agent_factory.create_event(
45
+ test_agent.id, event_type="connection", event_data={"action": "connected"}
46
+ )
47
+ await agent_factory.create_event(
48
+ test_agent.id, event_type="error", error_message="Connection timeout"
49
+ )
50
+
51
+ response = await client.get(
52
+ f"/api/agents/{test_agent.id}/events", headers=auth_headers
53
+ )
54
+
55
+ assert response.status_code == 200
56
+ data = response.json()
57
+ assert len(data) == 3
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_get_events_returns_expected_fields(
61
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
62
+ ):
63
+ """Should return events with all expected fields."""
64
+ await agent_factory.create_event(
65
+ test_agent.id,
66
+ event_type="heartbeat",
67
+ event_data={"status": "busy", "queue_depth": 5},
68
+ client_ip="192.168.1.100",
69
+ )
70
+
71
+ response = await client.get(
72
+ f"/api/agents/{test_agent.id}/events", headers=auth_headers
73
+ )
74
+
75
+ assert response.status_code == 200
76
+ data = response.json()
77
+ event = data[0]
78
+
79
+ assert "id" in event
80
+ assert event["event_type"] == "heartbeat"
81
+ assert event["event_data"]["status"] == "busy"
82
+ assert event["event_data"]["queue_depth"] == 5
83
+ assert event["client_ip"] == "192.168.1.100"
84
+ assert "created_at" in event
85
+
86
+ @pytest.mark.asyncio
87
+ async def test_get_events_ordered_by_time_desc(
88
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
89
+ ):
90
+ """Should return events in descending time order (newest first)."""
91
+ now = datetime.now(UTC)
92
+
93
+ # Create events with different timestamps
94
+ for i, event_type in enumerate(["event_a", "event_b", "event_c"]):
95
+ event = AgentEvent(
96
+ id=str(uuid.uuid4()),
97
+ agent_id=test_agent.id,
98
+ event_type=event_type,
99
+ )
100
+ db_session.add(event)
101
+ await db_session.commit()
102
+
103
+ # Manually set created_at to control ordering
104
+ event.created_at = now - timedelta(minutes=i)
105
+ await db_session.commit()
106
+
107
+ response = await client.get(
108
+ f"/api/agents/{test_agent.id}/events", headers=auth_headers
109
+ )
110
+
111
+ data = response.json()
112
+ event_types = [e["event_type"] for e in data]
113
+
114
+ # event_a is newest, event_c is oldest
115
+ assert event_types == ["event_a", "event_b", "event_c"]
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_get_events_agent_not_found(
119
+ self, client: AsyncClient, auth_headers: dict
120
+ ):
121
+ """Should return 404 for non-existent agent."""
122
+ fake_id = str(uuid.uuid4())
123
+
124
+ response = await client.get(
125
+ f"/api/agents/{fake_id}/events", headers=auth_headers
126
+ )
127
+
128
+ assert response.status_code == 404
129
+
130
+
131
+ class TestFilterEventsByType:
132
+ """Tests for event type filtering."""
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_filter_by_event_type(
136
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
137
+ ):
138
+ """Should filter events by event_type parameter."""
139
+ # Create different event types
140
+ await agent_factory.create_event(test_agent.id, event_type="heartbeat")
141
+ await agent_factory.create_event(test_agent.id, event_type="heartbeat")
142
+ await agent_factory.create_event(test_agent.id, event_type="error")
143
+ await agent_factory.create_event(test_agent.id, event_type="connection")
144
+
145
+ response = await client.get(
146
+ f"/api/agents/{test_agent.id}/events?event_type=heartbeat",
147
+ headers=auth_headers,
148
+ )
149
+
150
+ assert response.status_code == 200
151
+ data = response.json()
152
+ assert len(data) == 2
153
+ assert all(e["event_type"] == "heartbeat" for e in data)
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_filter_by_error_type(
157
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
158
+ ):
159
+ """Should filter by error event type."""
160
+ await agent_factory.create_event(
161
+ test_agent.id,
162
+ event_type="error",
163
+ error_type="LLM_ERROR",
164
+ error_message="Model unavailable",
165
+ )
166
+ await agent_factory.create_event(test_agent.id, event_type="heartbeat")
167
+
168
+ response = await client.get(
169
+ f"/api/agents/{test_agent.id}/events?event_type=error",
170
+ headers=auth_headers,
171
+ )
172
+
173
+ assert response.status_code == 200
174
+ data = response.json()
175
+ assert len(data) == 1
176
+ assert data[0]["error_type"] == "LLM_ERROR"
177
+ assert data[0]["error_message"] == "Model unavailable"
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_filter_returns_empty_for_no_match(
181
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
182
+ ):
183
+ """Should return empty list when no events match filter."""
184
+ await agent_factory.create_event(test_agent.id, event_type="heartbeat")
185
+
186
+ response = await client.get(
187
+ f"/api/agents/{test_agent.id}/events?event_type=error",
188
+ headers=auth_headers,
189
+ )
190
+
191
+ assert response.status_code == 200
192
+ data = response.json()
193
+ assert len(data) == 0
194
+
195
+
196
+ class TestEventsLimit:
197
+ """Tests for event limit parameter."""
198
+
199
+ @pytest.mark.asyncio
200
+ async def test_default_limit_50(
201
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
202
+ ):
203
+ """Should return max 50 events by default."""
204
+ # Create 60 events
205
+ for _ in range(60):
206
+ await agent_factory.create_event(test_agent.id, event_type="heartbeat")
207
+
208
+ response = await client.get(
209
+ f"/api/agents/{test_agent.id}/events", headers=auth_headers
210
+ )
211
+
212
+ assert response.status_code == 200
213
+ data = response.json()
214
+ assert len(data) == 50
215
+
216
+ @pytest.mark.asyncio
217
+ async def test_custom_limit(
218
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, agent_factory
219
+ ):
220
+ """Should respect custom limit parameter."""
221
+ for _ in range(20):
222
+ await agent_factory.create_event(test_agent.id, event_type="heartbeat")
223
+
224
+ response = await client.get(
225
+ f"/api/agents/{test_agent.id}/events?limit=10", headers=auth_headers
226
+ )
227
+
228
+ assert response.status_code == 200
229
+ data = response.json()
230
+ assert len(data) == 10
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_limit_max_500(
234
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
235
+ ):
236
+ """Should enforce maximum limit of 500."""
237
+ response = await client.get(
238
+ f"/api/agents/{test_agent.id}/events?limit=1000", headers=auth_headers
239
+ )
240
+
241
+ # Should reject limit > 500
242
+ assert response.status_code == 422
243
+
244
+ @pytest.mark.asyncio
245
+ async def test_limit_min_1(
246
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
247
+ ):
248
+ """Should enforce minimum limit of 1."""
249
+ response = await client.get(
250
+ f"/api/agents/{test_agent.id}/events?limit=0", headers=auth_headers
251
+ )
252
+
253
+ assert response.status_code == 422
254
+
255
+
256
+ class TestEventLoggingOnActions:
257
+ """Tests for automatic event logging on agent actions."""
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_heartbeat_logs_event(
261
+ self, client: AsyncClient, api_key_headers: dict, auth_headers: dict,
262
+ test_agent: Agent
263
+ ):
264
+ """Should log heartbeat event when heartbeat is received."""
265
+ # Send heartbeat
266
+ heartbeat_payload = {
267
+ "agent_id": test_agent.id,
268
+ "status": "online",
269
+ "sdk_version": "1.0.0",
270
+ }
271
+
272
+ await client.post(
273
+ "/api/agents/heartbeat", json=heartbeat_payload, headers=api_key_headers
274
+ )
275
+
276
+ # Check events
277
+ response = await client.get(
278
+ f"/api/agents/{test_agent.id}/events?event_type=heartbeat",
279
+ headers=auth_headers,
280
+ )
281
+
282
+ data = response.json()
283
+ assert len(data) >= 1
284
+
285
+ event = data[0]
286
+ assert event["event_type"] == "heartbeat"
287
+ assert event["event_data"]["status"] == "online"
288
+ assert event["event_data"]["sdk_version"] == "1.0.0"
289
+
290
+ @pytest.mark.asyncio
291
+ async def test_command_issued_logs_event(
292
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
293
+ ):
294
+ """Should log command_issued event when command is created."""
295
+ # Issue restart command
296
+ await client.post(
297
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
298
+ )
299
+
300
+ # Check events
301
+ response = await client.get(
302
+ f"/api/agents/{test_agent.id}/events?event_type=command_issued",
303
+ headers=auth_headers,
304
+ )
305
+
306
+ data = response.json()
307
+ assert len(data) >= 1
308
+
309
+ event = data[0]
310
+ assert event["event_type"] == "command_issued"
311
+ assert event["event_data"]["command_type"] == "restart"
312
+
313
+ @pytest.mark.asyncio
314
+ async def test_error_heartbeat_logs_error_details(
315
+ self, client: AsyncClient, api_key_headers: dict, auth_headers: dict,
316
+ test_agent: Agent
317
+ ):
318
+ """Should log error details in heartbeat event."""
319
+ heartbeat_payload = {
320
+ "agent_id": test_agent.id,
321
+ "status": "error",
322
+ "last_error": {
323
+ "message": "Failed to connect to database",
324
+ "code": "DB_CONNECTION_ERROR",
325
+ },
326
+ }
327
+
328
+ await client.post(
329
+ "/api/agents/heartbeat", json=heartbeat_payload, headers=api_key_headers
330
+ )
331
+
332
+ response = await client.get(
333
+ f"/api/agents/{test_agent.id}/events?event_type=heartbeat",
334
+ headers=auth_headers,
335
+ )
336
+
337
+ data = response.json()
338
+ event = data[0]
339
+ assert event["event_data"]["status"] == "error"
340
+
341
+
342
+ class TestEventWithClientIP:
343
+ """Tests for client IP tracking in events."""
344
+
345
+ @pytest.mark.asyncio
346
+ async def test_heartbeat_event_includes_client_ip(
347
+ self, client: AsyncClient, api_key_headers: dict, auth_headers: dict,
348
+ test_agent: Agent
349
+ ):
350
+ """Should include client IP in heartbeat event."""
351
+ heartbeat_payload = {
352
+ "agent_id": test_agent.id,
353
+ "status": "online",
354
+ }
355
+
356
+ await client.post(
357
+ "/api/agents/heartbeat", json=heartbeat_payload, headers=api_key_headers
358
+ )
359
+
360
+ response = await client.get(
361
+ f"/api/agents/{test_agent.id}/events?event_type=heartbeat",
362
+ headers=auth_headers,
363
+ )
364
+
365
+ data = response.json()
366
+ # In test environment, client IP should be captured
367
+ # (actual value depends on test setup)
368
+ assert "client_ip" in data[0]
369
+
370
+
371
+ class TestEventCleanup:
372
+ """Tests for event cleanup functionality."""
373
+
374
+ @pytest.mark.asyncio
375
+ async def test_cleanup_old_events(self, db_session, test_user, test_agent):
376
+ """Should clean up events older than retention period."""
377
+ from backend.services.agent_service import AgentService
378
+
379
+ # Create old event (100 days ago)
380
+ old_event = AgentEvent(
381
+ id=str(uuid.uuid4()),
382
+ agent_id=test_agent.id,
383
+ event_type="heartbeat",
384
+ )
385
+ db_session.add(old_event)
386
+ await db_session.commit()
387
+
388
+ # Manually set old timestamp
389
+ old_event.created_at = datetime.now(UTC) - timedelta(days=100)
390
+ await db_session.commit()
391
+
392
+ # Create recent event
393
+ recent_event = AgentEvent(
394
+ id=str(uuid.uuid4()),
395
+ agent_id=test_agent.id,
396
+ event_type="heartbeat",
397
+ )
398
+ db_session.add(recent_event)
399
+ await db_session.commit()
400
+
401
+ # Run cleanup with 90-day retention
402
+ svc = AgentService(db_session)
403
+ count = await svc.cleanup_old_events(retention_days=90)
404
+
405
+ assert count == 1
406
+
407
+ # Old event should be deleted
408
+ stmt = select(AgentEvent).where(AgentEvent.id == old_event.id)
409
+ result = await db_session.execute(stmt)
410
+ assert result.scalar_one_or_none() is None
411
+
412
+ # Recent event should remain
413
+ stmt = select(AgentEvent).where(AgentEvent.id == recent_event.id)
414
+ result = await db_session.execute(stmt)
415
+ assert result.scalar_one_or_none() is not None