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,456 @@
1
+ """
2
+ End-to-end tests for Agent Commands functionality.
3
+
4
+ Tests cover:
5
+ - Issue restart command
6
+ - Issue stop command
7
+ - Commands dispatched via heartbeat
8
+ - Command expiry
9
+ """
10
+
11
+ import pytest
12
+ import uuid
13
+ from datetime import datetime, timedelta, UTC
14
+ from httpx import AsyncClient
15
+ from sqlalchemy import select
16
+
17
+ from backend.models.agent import Agent, AgentCommand
18
+
19
+
20
+ class TestIssueRestartCommand:
21
+ """Tests for POST /api/agents/{agent_id}/restart"""
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_issue_restart_command_success(
25
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
26
+ ):
27
+ """Should successfully issue a restart command."""
28
+ response = await client.post(
29
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
30
+ )
31
+
32
+ assert response.status_code == 200
33
+ data = response.json()
34
+ assert data["command_type"] == "restart"
35
+ assert data["status"] == "pending"
36
+ assert "id" in data
37
+ assert "created_at" in data
38
+ assert "expires_at" in data
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_restart_increments_restart_count(
42
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
43
+ ):
44
+ """Should increment restart_count when restart command is issued."""
45
+ original_count = test_agent.restart_count
46
+
47
+ await client.post(
48
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
49
+ )
50
+
51
+ await db_session.refresh(test_agent)
52
+
53
+ assert test_agent.restart_count == original_count + 1
54
+ assert test_agent.last_restart_at is not None
55
+ assert test_agent.last_restart_reason == "user_requested"
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_restart_creates_command_record(
59
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
60
+ ):
61
+ """Should create a command record in the database."""
62
+ response = await client.post(
63
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
64
+ )
65
+
66
+ command_id = response.json()["id"]
67
+
68
+ stmt = select(AgentCommand).where(AgentCommand.id == command_id)
69
+ result = await db_session.execute(stmt)
70
+ command = result.scalar_one_or_none()
71
+
72
+ assert command is not None
73
+ assert command.command_type == "restart"
74
+ assert command.agent_id == test_agent.id
75
+ assert command.status == "pending"
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_restart_agent_not_found(
79
+ self, client: AsyncClient, auth_headers: dict
80
+ ):
81
+ """Should return 404 for non-existent agent."""
82
+ fake_id = str(uuid.uuid4())
83
+
84
+ response = await client.post(
85
+ f"/api/agents/{fake_id}/restart", headers=auth_headers
86
+ )
87
+
88
+ assert response.status_code == 404
89
+
90
+
91
+ class TestIssueStopCommand:
92
+ """Tests for POST /api/agents/{agent_id}/stop"""
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_issue_stop_command_success(
96
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
97
+ ):
98
+ """Should successfully issue a stop command."""
99
+ response = await client.post(
100
+ f"/api/agents/{test_agent.id}/stop", headers=auth_headers
101
+ )
102
+
103
+ assert response.status_code == 200
104
+ data = response.json()
105
+ assert data["command_type"] == "stop"
106
+ assert data["status"] == "pending"
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_stop_does_not_increment_restart_count(
110
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
111
+ ):
112
+ """Should not increment restart_count for stop command."""
113
+ original_count = test_agent.restart_count
114
+
115
+ await client.post(
116
+ f"/api/agents/{test_agent.id}/stop", headers=auth_headers
117
+ )
118
+
119
+ await db_session.refresh(test_agent)
120
+
121
+ assert test_agent.restart_count == original_count
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_stop_agent_not_found(
125
+ self, client: AsyncClient, auth_headers: dict
126
+ ):
127
+ """Should return 404 for non-existent agent."""
128
+ fake_id = str(uuid.uuid4())
129
+
130
+ response = await client.post(
131
+ f"/api/agents/{fake_id}/stop", headers=auth_headers
132
+ )
133
+
134
+ assert response.status_code == 404
135
+
136
+
137
+ class TestIssueGenericCommand:
138
+ """Tests for POST /api/agents/{agent_id}/command"""
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_issue_restart_via_generic_endpoint(
142
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
143
+ ):
144
+ """Should issue restart command via generic endpoint."""
145
+ payload = {"command_type": "restart"}
146
+
147
+ response = await client.post(
148
+ f"/api/agents/{test_agent.id}/command",
149
+ json=payload,
150
+ headers=auth_headers,
151
+ )
152
+
153
+ assert response.status_code == 200
154
+ assert response.json()["command_type"] == "restart"
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_issue_stop_via_generic_endpoint(
158
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
159
+ ):
160
+ """Should issue stop command via generic endpoint."""
161
+ payload = {"command_type": "stop"}
162
+
163
+ response = await client.post(
164
+ f"/api/agents/{test_agent.id}/command",
165
+ json=payload,
166
+ headers=auth_headers,
167
+ )
168
+
169
+ assert response.status_code == 200
170
+ assert response.json()["command_type"] == "stop"
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_issue_config_refresh_command(
174
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
175
+ ):
176
+ """Should issue config_refresh command."""
177
+ payload = {"command_type": "config_refresh"}
178
+
179
+ response = await client.post(
180
+ f"/api/agents/{test_agent.id}/command",
181
+ json=payload,
182
+ headers=auth_headers,
183
+ )
184
+
185
+ assert response.status_code == 200
186
+ assert response.json()["command_type"] == "config_refresh"
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_issue_command_with_payload(
190
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
191
+ ):
192
+ """Should issue command with custom payload."""
193
+ payload = {
194
+ "command_type": "config_refresh",
195
+ "payload": {"config_key": "max_tokens", "config_value": 4096},
196
+ }
197
+
198
+ response = await client.post(
199
+ f"/api/agents/{test_agent.id}/command",
200
+ json=payload,
201
+ headers=auth_headers,
202
+ )
203
+
204
+ assert response.status_code == 200
205
+ command_id = response.json()["id"]
206
+
207
+ stmt = select(AgentCommand).where(AgentCommand.id == command_id)
208
+ result = await db_session.execute(stmt)
209
+ command = result.scalar_one_or_none()
210
+
211
+ assert command.payload == {"config_key": "max_tokens", "config_value": 4096}
212
+
213
+ @pytest.mark.asyncio
214
+ async def test_issue_invalid_command_type(
215
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
216
+ ):
217
+ """Should reject invalid command type."""
218
+ payload = {"command_type": "invalid_command"}
219
+
220
+ response = await client.post(
221
+ f"/api/agents/{test_agent.id}/command",
222
+ json=payload,
223
+ headers=auth_headers,
224
+ )
225
+
226
+ assert response.status_code == 422
227
+
228
+
229
+ class TestCommandDispatchViaHeartbeat:
230
+ """Tests for command dispatch via heartbeat."""
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_command_delivered_via_heartbeat(
234
+ self, client: AsyncClient, auth_headers: dict, api_key_headers: dict,
235
+ test_agent: Agent
236
+ ):
237
+ """Should deliver pending command via heartbeat response."""
238
+ # Issue a restart command
239
+ await client.post(
240
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
241
+ )
242
+
243
+ # Send heartbeat
244
+ heartbeat_payload = {
245
+ "agent_id": test_agent.id,
246
+ "status": "online",
247
+ }
248
+
249
+ response = await client.post(
250
+ "/api/agents/heartbeat",
251
+ json=heartbeat_payload,
252
+ headers=api_key_headers,
253
+ )
254
+
255
+ assert response.status_code == 200
256
+ data = response.json()
257
+ assert len(data["commands"]) == 1
258
+ assert data["commands"][0]["type"] == "restart"
259
+
260
+ @pytest.mark.asyncio
261
+ async def test_command_has_signature(
262
+ self, client: AsyncClient, auth_headers: dict, api_key_headers: dict,
263
+ test_agent: Agent
264
+ ):
265
+ """Should include signature in delivered command."""
266
+ # Issue command
267
+ await client.post(
268
+ f"/api/agents/{test_agent.id}/stop", headers=auth_headers
269
+ )
270
+
271
+ # Send heartbeat
272
+ heartbeat_payload = {
273
+ "agent_id": test_agent.id,
274
+ "status": "online",
275
+ }
276
+
277
+ response = await client.post(
278
+ "/api/agents/heartbeat",
279
+ json=heartbeat_payload,
280
+ headers=api_key_headers,
281
+ )
282
+
283
+ data = response.json()
284
+ command = data["commands"][0]
285
+ assert "signature" in command
286
+ assert len(command["signature"]) == 64 # SHA256 hex
287
+
288
+ @pytest.mark.asyncio
289
+ async def test_command_status_changes_to_dispatched(
290
+ self, client: AsyncClient, auth_headers: dict, api_key_headers: dict,
291
+ test_agent: Agent, db_session
292
+ ):
293
+ """Should change command status to 'dispatched' after delivery."""
294
+ # Issue command
295
+ cmd_response = await client.post(
296
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
297
+ )
298
+ command_id = cmd_response.json()["id"]
299
+
300
+ # Send heartbeat
301
+ heartbeat_payload = {
302
+ "agent_id": test_agent.id,
303
+ "status": "online",
304
+ }
305
+
306
+ await client.post(
307
+ "/api/agents/heartbeat",
308
+ json=heartbeat_payload,
309
+ headers=api_key_headers,
310
+ )
311
+
312
+ # Check command status
313
+ stmt = select(AgentCommand).where(AgentCommand.id == command_id)
314
+ result = await db_session.execute(stmt)
315
+ command = result.scalar_one_or_none()
316
+
317
+ assert command.status == "dispatched"
318
+ assert command.dispatched_at is not None
319
+
320
+ @pytest.mark.asyncio
321
+ async def test_command_not_delivered_twice(
322
+ self, client: AsyncClient, auth_headers: dict, api_key_headers: dict,
323
+ test_agent: Agent
324
+ ):
325
+ """Should not deliver same command multiple times."""
326
+ # Issue command
327
+ await client.post(
328
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
329
+ )
330
+
331
+ heartbeat_payload = {
332
+ "agent_id": test_agent.id,
333
+ "status": "online",
334
+ }
335
+
336
+ # First heartbeat - should get command
337
+ response1 = await client.post(
338
+ "/api/agents/heartbeat",
339
+ json=heartbeat_payload,
340
+ headers=api_key_headers,
341
+ )
342
+ assert len(response1.json()["commands"]) == 1
343
+
344
+ # Second heartbeat - should not get command again
345
+ response2 = await client.post(
346
+ "/api/agents/heartbeat",
347
+ json=heartbeat_payload,
348
+ headers=api_key_headers,
349
+ )
350
+ assert len(response2.json()["commands"]) == 0
351
+
352
+ @pytest.mark.asyncio
353
+ async def test_multiple_commands_delivered_in_order(
354
+ self, client: AsyncClient, auth_headers: dict, api_key_headers: dict,
355
+ test_agent: Agent
356
+ ):
357
+ """Should deliver multiple pending commands in creation order."""
358
+ # Issue multiple commands
359
+ await client.post(
360
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
361
+ )
362
+ await client.post(
363
+ f"/api/agents/{test_agent.id}/stop", headers=auth_headers
364
+ )
365
+
366
+ heartbeat_payload = {
367
+ "agent_id": test_agent.id,
368
+ "status": "online",
369
+ }
370
+
371
+ response = await client.post(
372
+ "/api/agents/heartbeat",
373
+ json=heartbeat_payload,
374
+ headers=api_key_headers,
375
+ )
376
+
377
+ data = response.json()
378
+ assert len(data["commands"]) == 2
379
+ assert data["commands"][0]["type"] == "restart"
380
+ assert data["commands"][1]["type"] == "stop"
381
+
382
+
383
+ class TestCommandExpiry:
384
+ """Tests for command expiration."""
385
+
386
+ @pytest.mark.asyncio
387
+ async def test_command_has_expiry_time(
388
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
389
+ ):
390
+ """Should set expiry time on command (default ~5 minutes)."""
391
+ response = await client.post(
392
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
393
+ )
394
+
395
+ data = response.json()
396
+ expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
397
+ created_at = datetime.fromisoformat(data["created_at"].replace("Z", "+00:00"))
398
+
399
+ # Should expire within 3-10 minutes
400
+ assert expires_at > created_at + timedelta(minutes=3)
401
+ assert expires_at < created_at + timedelta(minutes=10)
402
+
403
+ @pytest.mark.asyncio
404
+ async def test_expired_command_not_delivered(
405
+ self, client: AsyncClient, api_key_headers: dict, test_agent: Agent,
406
+ agent_factory
407
+ ):
408
+ """Should not deliver expired commands."""
409
+ # Create an expired command
410
+ await agent_factory.create_command(
411
+ test_agent.id,
412
+ command_type="restart",
413
+ status="pending",
414
+ expires_at=datetime.now(UTC) - timedelta(minutes=10),
415
+ )
416
+
417
+ heartbeat_payload = {
418
+ "agent_id": test_agent.id,
419
+ "status": "online",
420
+ }
421
+
422
+ response = await client.post(
423
+ "/api/agents/heartbeat",
424
+ json=heartbeat_payload,
425
+ headers=api_key_headers,
426
+ )
427
+
428
+ data = response.json()
429
+ assert len(data["commands"]) == 0
430
+
431
+
432
+ class TestCommandEventLogging:
433
+ """Tests for command event logging."""
434
+
435
+ @pytest.mark.asyncio
436
+ async def test_command_issued_event_logged(
437
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent, db_session
438
+ ):
439
+ """Should log command_issued event when command is created."""
440
+ from backend.models.agent import AgentEvent
441
+
442
+ await client.post(
443
+ f"/api/agents/{test_agent.id}/restart", headers=auth_headers
444
+ )
445
+
446
+ # Check for event
447
+ stmt = (
448
+ select(AgentEvent)
449
+ .where(AgentEvent.agent_id == test_agent.id)
450
+ .where(AgentEvent.event_type == "command_issued")
451
+ )
452
+ result = await db_session.execute(stmt)
453
+ event = result.scalar_one_or_none()
454
+
455
+ assert event is not None
456
+ assert event.event_data["command_type"] == "restart"