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,349 @@
1
+ """
2
+ End-to-end tests for Agent Setup Flow.
3
+
4
+ Tests cover:
5
+ - Generate setup token
6
+ - Token expiry validation
7
+ - Consume setup token successfully
8
+ - Reject used/expired tokens
9
+ - Agent transitions to "online" state after registration
10
+ - API key is created with type="agent"
11
+ """
12
+
13
+ import hashlib
14
+ import pytest
15
+ import uuid
16
+ from datetime import datetime, timedelta, UTC
17
+ from httpx import AsyncClient
18
+
19
+ from backend.models.agent import Agent, AgentSetupToken
20
+ from backend.models.api_key import ApiKey
21
+
22
+
23
+ class TestGenerateSetupToken:
24
+ """Tests for POST /api/agents/{agent_id}/setup-token"""
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_generate_setup_token_success(
28
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
29
+ ):
30
+ """Should generate a valid setup token for an agent."""
31
+ response = await client.post(
32
+ f"/api/agents/{test_agent.id}/setup-token", headers=auth_headers
33
+ )
34
+
35
+ assert response.status_code == 200
36
+ data = response.json()
37
+ assert "token" in data
38
+ assert data["token"].startswith("kairo_setup_")
39
+ assert "expires_at" in data
40
+ assert data["agent_id"] == test_agent.id
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_setup_token_expiry_in_future(
44
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
45
+ ):
46
+ """Should set token expiry in the future (default 15 minutes)."""
47
+ response = await client.post(
48
+ f"/api/agents/{test_agent.id}/setup-token", headers=auth_headers
49
+ )
50
+
51
+ assert response.status_code == 200
52
+ data = response.json()
53
+
54
+ expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
55
+ now = datetime.now(UTC)
56
+
57
+ # Token should expire within 10-20 minutes from now
58
+ assert expires_at > now + timedelta(minutes=10)
59
+ assert expires_at < now + timedelta(minutes=20)
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_generate_token_for_nonexistent_agent(
63
+ self, client: AsyncClient, auth_headers: dict
64
+ ):
65
+ """Should return 404 for non-existent agent."""
66
+ fake_id = str(uuid.uuid4())
67
+
68
+ response = await client.post(
69
+ f"/api/agents/{fake_id}/setup-token", headers=auth_headers
70
+ )
71
+
72
+ assert response.status_code == 404
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_generate_multiple_tokens(
76
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
77
+ ):
78
+ """Should allow generating multiple tokens (old ones remain valid until used/expired)."""
79
+ response1 = await client.post(
80
+ f"/api/agents/{test_agent.id}/setup-token", headers=auth_headers
81
+ )
82
+ response2 = await client.post(
83
+ f"/api/agents/{test_agent.id}/setup-token", headers=auth_headers
84
+ )
85
+
86
+ assert response1.status_code == 200
87
+ assert response2.status_code == 200
88
+
89
+ token1 = response1.json()["token"]
90
+ token2 = response2.json()["token"]
91
+ assert token1 != token2
92
+
93
+
94
+ class TestConsumeSetupToken:
95
+ """Tests for POST /api/agents/register"""
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_consume_token_success(
99
+ self, client: AsyncClient, test_agent: Agent, agent_factory, db_session
100
+ ):
101
+ """Should successfully register agent with valid token."""
102
+ # Generate a setup token
103
+ token, _ = await agent_factory.create_setup_token(test_agent.id)
104
+
105
+ payload = {
106
+ "setup_token": token,
107
+ "sdk_version": "1.0.0",
108
+ "host_info": {
109
+ "hostname": "test-machine",
110
+ "ip": "192.168.1.100",
111
+ "os": "linux",
112
+ "memory_mb": 8192,
113
+ },
114
+ }
115
+
116
+ response = await client.post("/api/agents/register", json=payload)
117
+
118
+ assert response.status_code == 200
119
+ data = response.json()
120
+ assert data["agent_id"] == test_agent.id
121
+ assert data["name"] == test_agent.name
122
+ assert data["model_preference"] == test_agent.model_preference
123
+ assert "api_key" in data
124
+ assert data["api_key"].startswith("kairo_agent_")
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_agent_state_transitions_to_online(
128
+ self, client: AsyncClient, test_agent: Agent, agent_factory, db_session
129
+ ):
130
+ """Should transition agent state to 'online' after registration."""
131
+ token, _ = await agent_factory.create_setup_token(test_agent.id)
132
+
133
+ payload = {
134
+ "setup_token": token,
135
+ "sdk_version": "1.0.0",
136
+ }
137
+
138
+ await client.post("/api/agents/register", json=payload)
139
+
140
+ # Refresh agent from database
141
+ await db_session.refresh(test_agent)
142
+
143
+ assert test_agent.state == "online"
144
+ assert test_agent.first_connected_at is not None
145
+ assert test_agent.last_online_at is not None
146
+ assert test_agent.sdk_version == "1.0.0"
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_api_key_created_with_agent_type(
150
+ self, client: AsyncClient, test_agent: Agent, agent_factory, db_session
151
+ ):
152
+ """Should create API key with type='agent' after registration."""
153
+ from sqlalchemy import select
154
+
155
+ token, _ = await agent_factory.create_setup_token(test_agent.id)
156
+
157
+ payload = {
158
+ "setup_token": token,
159
+ "sdk_version": "1.0.0",
160
+ }
161
+
162
+ response = await client.post("/api/agents/register", json=payload)
163
+ data = response.json()
164
+
165
+ # Find the created API key
166
+ stmt = select(ApiKey).where(ApiKey.agent_id == test_agent.id)
167
+ result = await db_session.execute(stmt)
168
+ api_key = result.scalar_one_or_none()
169
+
170
+ assert api_key is not None
171
+ assert api_key.key_type == "agent"
172
+ assert api_key.is_active is True
173
+ assert f"Agent: {test_agent.name}" in api_key.name
174
+
175
+ @pytest.mark.asyncio
176
+ async def test_reject_used_token(
177
+ self, client: AsyncClient, test_agent: Agent, agent_factory
178
+ ):
179
+ """Should reject a token that has already been used."""
180
+ # Create a used token
181
+ token, setup_token = await agent_factory.create_setup_token(
182
+ test_agent.id, used=True
183
+ )
184
+
185
+ payload = {
186
+ "setup_token": token,
187
+ "sdk_version": "1.0.0",
188
+ }
189
+
190
+ response = await client.post("/api/agents/register", json=payload)
191
+
192
+ assert response.status_code == 401
193
+ assert "Invalid or expired" in response.json()["detail"]
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_reject_expired_token(
197
+ self, client: AsyncClient, test_agent: Agent, db_session
198
+ ):
199
+ """Should reject a token that has expired."""
200
+ import secrets
201
+
202
+ # Create an expired token directly
203
+ token = f"kairo_setup_{secrets.token_urlsafe(32)}"
204
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
205
+ expires_at = datetime.now(UTC) - timedelta(hours=1) # Expired
206
+
207
+ setup_token = AgentSetupToken(
208
+ id=str(uuid.uuid4()),
209
+ agent_id=test_agent.id,
210
+ user_id=test_agent.user_id,
211
+ token_hash=token_hash,
212
+ expires_at=expires_at,
213
+ used=False,
214
+ )
215
+ db_session.add(setup_token)
216
+ await db_session.commit()
217
+
218
+ payload = {
219
+ "setup_token": token,
220
+ "sdk_version": "1.0.0",
221
+ }
222
+
223
+ response = await client.post("/api/agents/register", json=payload)
224
+
225
+ assert response.status_code == 401
226
+ assert "Invalid or expired" in response.json()["detail"]
227
+
228
+ @pytest.mark.asyncio
229
+ async def test_reject_invalid_token(self, client: AsyncClient):
230
+ """Should reject an invalid token format."""
231
+ payload = {
232
+ "setup_token": "invalid_token_format",
233
+ "sdk_version": "1.0.0",
234
+ }
235
+
236
+ response = await client.post("/api/agents/register", json=payload)
237
+
238
+ assert response.status_code == 401
239
+
240
+ @pytest.mark.asyncio
241
+ async def test_reject_nonexistent_token(self, client: AsyncClient):
242
+ """Should reject a token that does not exist in database."""
243
+ import secrets
244
+
245
+ payload = {
246
+ "setup_token": f"kairo_setup_{secrets.token_urlsafe(32)}",
247
+ "sdk_version": "1.0.0",
248
+ }
249
+
250
+ response = await client.post("/api/agents/register", json=payload)
251
+
252
+ assert response.status_code == 401
253
+
254
+ @pytest.mark.asyncio
255
+ async def test_token_marked_as_used_after_consumption(
256
+ self, client: AsyncClient, test_agent: Agent, agent_factory, db_session
257
+ ):
258
+ """Should mark token as used after successful consumption."""
259
+ token, setup_token = await agent_factory.create_setup_token(test_agent.id)
260
+
261
+ payload = {
262
+ "setup_token": token,
263
+ "sdk_version": "1.0.0",
264
+ }
265
+
266
+ await client.post("/api/agents/register", json=payload)
267
+
268
+ # Refresh token from database
269
+ await db_session.refresh(setup_token)
270
+
271
+ assert setup_token.used is True
272
+ assert setup_token.used_at is not None
273
+
274
+ @pytest.mark.asyncio
275
+ async def test_host_info_stored_on_agent(
276
+ self, client: AsyncClient, test_agent: Agent, agent_factory, db_session
277
+ ):
278
+ """Should store host_info on the agent after registration."""
279
+ token, _ = await agent_factory.create_setup_token(test_agent.id)
280
+
281
+ host_info = {
282
+ "hostname": "production-server",
283
+ "ip": "10.0.0.50",
284
+ "os": "ubuntu-22.04",
285
+ "memory_mb": 16384,
286
+ }
287
+
288
+ payload = {
289
+ "setup_token": token,
290
+ "sdk_version": "2.0.0",
291
+ "host_info": host_info,
292
+ }
293
+
294
+ await client.post("/api/agents/register", json=payload)
295
+
296
+ await db_session.refresh(test_agent)
297
+
298
+ assert test_agent.host_info is not None
299
+ assert test_agent.host_info["hostname"] == "production-server"
300
+ assert test_agent.host_info["os"] == "ubuntu-22.04"
301
+
302
+
303
+ class TestConnectionStatus:
304
+ """Tests for GET /api/agents/{agent_id}/connection-status"""
305
+
306
+ @pytest.mark.asyncio
307
+ async def test_connection_status_not_connected(
308
+ self, client: AsyncClient, auth_headers: dict, test_agent: Agent
309
+ ):
310
+ """Should return connected=false for unregistered agent."""
311
+ response = await client.get(
312
+ f"/api/agents/{test_agent.id}/connection-status", headers=auth_headers
313
+ )
314
+
315
+ assert response.status_code == 200
316
+ data = response.json()
317
+ assert data["connected"] is False
318
+ assert data["state"] == "created"
319
+ assert data["first_connected_at"] is None
320
+
321
+ @pytest.mark.asyncio
322
+ async def test_connection_status_connected(
323
+ self, client: AsyncClient, auth_headers: dict, online_agent: Agent
324
+ ):
325
+ """Should return connected=true for registered online agent."""
326
+ response = await client.get(
327
+ f"/api/agents/{online_agent.id}/connection-status", headers=auth_headers
328
+ )
329
+
330
+ assert response.status_code == 200
331
+ data = response.json()
332
+ assert data["connected"] is True
333
+ assert data["state"] == "online"
334
+ assert data["first_connected_at"] is not None
335
+ assert data["sdk_version"] == "1.0.0"
336
+ assert data["host_info"] is not None
337
+
338
+ @pytest.mark.asyncio
339
+ async def test_connection_status_not_found(
340
+ self, client: AsyncClient, auth_headers: dict
341
+ ):
342
+ """Should return 404 for non-existent agent."""
343
+ fake_id = str(uuid.uuid4())
344
+
345
+ response = await client.get(
346
+ f"/api/agents/{fake_id}/connection-status", headers=auth_headers
347
+ )
348
+
349
+ assert response.status_code == 404
@@ -0,0 +1,246 @@
1
+ """Add agent dashboard tables and extend agents
2
+
3
+ Revision ID: 010
4
+ Revises: 009
5
+ Create Date: 2026-02-03 00:00:00.000000
6
+ """
7
+
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ from sqlalchemy.dialects.postgresql import JSONB
11
+
12
+ revision = "010"
13
+ down_revision = "009"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ # Extend agents table with dashboard fields
20
+ op.add_column("agents", sa.Column("state", sa.String(), server_default="created"))
21
+ op.add_column("agents", sa.Column("agent_type", sa.String(), server_default="sdk"))
22
+ op.add_column("agents", sa.Column("sdk_version", sa.String(), nullable=True))
23
+ op.add_column("agents", sa.Column("host_info", JSONB, nullable=True))
24
+ op.add_column("agents", sa.Column("last_online_at", sa.DateTime(timezone=True), nullable=True))
25
+ op.add_column("agents", sa.Column("first_connected_at", sa.DateTime(timezone=True), nullable=True))
26
+ op.add_column("agents", sa.Column("restart_count", sa.Integer(), server_default="0"))
27
+ op.add_column("agents", sa.Column("last_restart_at", sa.DateTime(timezone=True), nullable=True))
28
+ op.add_column("agents", sa.Column("last_restart_reason", sa.String(), nullable=True))
29
+ op.add_column("agents", sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True))
30
+ op.add_column("agents", sa.Column("last_error_message", sa.Text(), nullable=True))
31
+ op.add_column("agents", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
32
+
33
+ op.create_index("ix_agents_state", "agents", ["state"])
34
+ op.create_index("ix_agents_user_state", "agents", ["user_id", "state"])
35
+
36
+ # Add agent_id to api_keys for agent-scoped keys
37
+ op.add_column("api_keys", sa.Column(
38
+ "agent_id",
39
+ sa.String(),
40
+ sa.ForeignKey("agents.id", ondelete="CASCADE"),
41
+ nullable=True
42
+ ))
43
+ op.create_index("ix_api_keys_agent_id", "api_keys", ["agent_id"])
44
+
45
+ # Agent metrics - 1 minute granularity (30 day retention)
46
+ op.create_table(
47
+ "agent_metrics_1m",
48
+ sa.Column("id", sa.String(), primary_key=True),
49
+ sa.Column("agent_id", sa.String(), sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
50
+ sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
51
+ sa.Column("request_count", sa.Integer(), server_default="0"),
52
+ sa.Column("error_count", sa.Integer(), server_default="0"),
53
+ sa.Column("timeout_count", sa.Integer(), server_default="0"),
54
+ sa.Column("input_tokens", sa.BigInteger(), server_default="0"),
55
+ sa.Column("output_tokens", sa.BigInteger(), server_default="0"),
56
+ sa.Column("total_latency_ms", sa.BigInteger(), server_default="0"),
57
+ sa.Column("min_latency_ms", sa.Integer(), nullable=True),
58
+ sa.Column("max_latency_ms", sa.Integer(), nullable=True),
59
+ sa.Column("tool_calls", sa.Integer(), server_default="0"),
60
+ sa.Column("tool_errors", sa.Integer(), server_default="0"),
61
+ sa.Column("tool_breakdown", JSONB, nullable=True),
62
+ sa.Column("model_breakdown", JSONB, nullable=True),
63
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
64
+ )
65
+ op.create_index("ix_agent_metrics_1m_agent_time", "agent_metrics_1m", ["agent_id", "bucket_time"])
66
+ op.create_index("ix_agent_metrics_1m_bucket_time", "agent_metrics_1m", ["bucket_time"])
67
+
68
+ # Agent metrics - 1 hour granularity (1 year retention)
69
+ op.create_table(
70
+ "agent_metrics_1h",
71
+ sa.Column("id", sa.String(), primary_key=True),
72
+ sa.Column("agent_id", sa.String(), sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
73
+ sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
74
+ sa.Column("request_count", sa.Integer(), server_default="0"),
75
+ sa.Column("error_count", sa.Integer(), server_default="0"),
76
+ sa.Column("timeout_count", sa.Integer(), server_default="0"),
77
+ sa.Column("input_tokens", sa.BigInteger(), server_default="0"),
78
+ sa.Column("output_tokens", sa.BigInteger(), server_default="0"),
79
+ sa.Column("total_latency_ms", sa.BigInteger(), server_default="0"),
80
+ sa.Column("min_latency_ms", sa.Integer(), nullable=True),
81
+ sa.Column("max_latency_ms", sa.Integer(), nullable=True),
82
+ sa.Column("p50_latency_ms", sa.Integer(), nullable=True),
83
+ sa.Column("p99_latency_ms", sa.Integer(), nullable=True),
84
+ sa.Column("tool_calls", sa.Integer(), server_default="0"),
85
+ sa.Column("tool_errors", sa.Integer(), server_default="0"),
86
+ sa.Column("tool_breakdown", JSONB, nullable=True),
87
+ sa.Column("model_breakdown", JSONB, nullable=True),
88
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
89
+ )
90
+ op.create_index("ix_agent_metrics_1h_agent_time", "agent_metrics_1h", ["agent_id", "bucket_time"])
91
+ op.create_index("ix_agent_metrics_1h_bucket_time", "agent_metrics_1h", ["bucket_time"])
92
+
93
+ # Agent metrics - daily granularity (2 year retention)
94
+ op.create_table(
95
+ "agent_metrics_daily",
96
+ sa.Column("id", sa.String(), primary_key=True),
97
+ sa.Column("agent_id", sa.String(), sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
98
+ sa.Column("date", sa.Date(), nullable=False),
99
+ sa.Column("request_count", sa.Integer(), server_default="0"),
100
+ sa.Column("error_count", sa.Integer(), server_default="0"),
101
+ sa.Column("timeout_count", sa.Integer(), server_default="0"),
102
+ sa.Column("input_tokens", sa.BigInteger(), server_default="0"),
103
+ sa.Column("output_tokens", sa.BigInteger(), server_default="0"),
104
+ sa.Column("total_latency_ms", sa.BigInteger(), server_default="0"),
105
+ sa.Column("min_latency_ms", sa.Integer(), nullable=True),
106
+ sa.Column("max_latency_ms", sa.Integer(), nullable=True),
107
+ sa.Column("avg_latency_ms", sa.Integer(), nullable=True),
108
+ sa.Column("p50_latency_ms", sa.Integer(), nullable=True),
109
+ sa.Column("p99_latency_ms", sa.Integer(), nullable=True),
110
+ sa.Column("tool_calls", sa.Integer(), server_default="0"),
111
+ sa.Column("tool_errors", sa.Integer(), server_default="0"),
112
+ sa.Column("tool_breakdown", JSONB, nullable=True),
113
+ sa.Column("model_breakdown", JSONB, nullable=True),
114
+ sa.Column("uptime_seconds", sa.Integer(), server_default="0"),
115
+ sa.Column("downtime_seconds", sa.Integer(), server_default="0"),
116
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
117
+ )
118
+ op.create_index("ix_agent_metrics_daily_agent_date", "agent_metrics_daily", ["agent_id", "date"], unique=True)
119
+ op.create_index("ix_agent_metrics_daily_date", "agent_metrics_daily", ["date"])
120
+
121
+ # Agent events log
122
+ op.create_table(
123
+ "agent_events",
124
+ sa.Column("id", sa.String(), primary_key=True),
125
+ sa.Column("agent_id", sa.String(), sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
126
+ sa.Column("event_type", sa.String(), nullable=False),
127
+ sa.Column("event_data", JSONB, nullable=True),
128
+ sa.Column("error_type", sa.String(), nullable=True),
129
+ sa.Column("error_message", sa.Text(), nullable=True),
130
+ sa.Column("session_id", sa.String(), nullable=True),
131
+ sa.Column("client_ip", sa.String(), nullable=True),
132
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
133
+ )
134
+ op.create_index("ix_agent_events_agent_time", "agent_events", ["agent_id", "created_at"])
135
+ op.create_index("ix_agent_events_agent_type", "agent_events", ["agent_id", "event_type"])
136
+ op.create_index("ix_agent_events_created_at", "agent_events", ["created_at"])
137
+
138
+ # Agent alert configurations
139
+ op.create_table(
140
+ "agent_alert_configs",
141
+ sa.Column("id", sa.String(), primary_key=True),
142
+ sa.Column("agent_id", sa.String(), sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
143
+ sa.Column("user_id", sa.String(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
144
+ sa.Column("name", sa.String(), nullable=False),
145
+ sa.Column("alert_type", sa.String(), nullable=False),
146
+ sa.Column("metric", sa.String(), nullable=False),
147
+ sa.Column("condition", sa.String(), nullable=False),
148
+ sa.Column("threshold", sa.Float(), nullable=False),
149
+ sa.Column("window_seconds", sa.Integer(), server_default="300"),
150
+ sa.Column("cooldown_seconds", sa.Integer(), server_default="3600"),
151
+ sa.Column("severity", sa.String(), server_default="'warning'"),
152
+ sa.Column("channels", JSONB, server_default="'[\"email\"]'"),
153
+ sa.Column("is_enabled", sa.Boolean(), server_default="true"),
154
+ sa.Column("last_triggered_at", sa.DateTime(timezone=True), nullable=True),
155
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
156
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
157
+ )
158
+ op.create_index("ix_agent_alert_configs_agent", "agent_alert_configs", ["agent_id"])
159
+ op.create_index("ix_agent_alert_configs_user", "agent_alert_configs", ["user_id"])
160
+
161
+ # Agent alert history
162
+ op.create_table(
163
+ "agent_alert_history",
164
+ sa.Column("id", sa.String(), primary_key=True),
165
+ sa.Column("config_id", sa.String(), sa.ForeignKey("agent_alert_configs.id", ondelete="CASCADE"), nullable=False),
166
+ sa.Column("agent_id", sa.String(), sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
167
+ sa.Column("alert_type", sa.String(), nullable=False),
168
+ sa.Column("severity", sa.String(), nullable=False),
169
+ sa.Column("status", sa.String(), server_default="'triggered'"),
170
+ sa.Column("message", sa.Text(), nullable=True),
171
+ sa.Column("trigger_value", sa.Float(), nullable=True),
172
+ sa.Column("threshold_value", sa.Float(), nullable=True),
173
+ sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
174
+ sa.Column("acknowledged_by", sa.String(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
175
+ sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
176
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
177
+ )
178
+ op.create_index("ix_agent_alert_history_agent", "agent_alert_history", ["agent_id"])
179
+ op.create_index("ix_agent_alert_history_config", "agent_alert_history", ["config_id"])
180
+ op.create_index("ix_agent_alert_history_status", "agent_alert_history", ["status"])
181
+ op.create_index("ix_agent_alert_history_created", "agent_alert_history", ["created_at"])
182
+
183
+ # Agent setup tokens (one-time tokens for guided wizard)
184
+ op.create_table(
185
+ "agent_setup_tokens",
186
+ sa.Column("id", sa.String(), primary_key=True),
187
+ sa.Column("agent_id", sa.String(), sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
188
+ sa.Column("user_id", sa.String(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
189
+ sa.Column("token_hash", sa.String(), nullable=False, unique=True),
190
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
191
+ sa.Column("used", sa.Boolean(), server_default="false"),
192
+ sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
193
+ sa.Column("used_from_ip", sa.String(), nullable=True),
194
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
195
+ )
196
+ op.create_index("ix_agent_setup_tokens_agent", "agent_setup_tokens", ["agent_id"])
197
+ op.create_index("ix_agent_setup_tokens_token_hash", "agent_setup_tokens", ["token_hash"], unique=True)
198
+
199
+ # Agent pending commands (for restart/stop via heartbeat)
200
+ op.create_table(
201
+ "agent_commands",
202
+ sa.Column("id", sa.String(), primary_key=True),
203
+ sa.Column("agent_id", sa.String(), sa.ForeignKey("agents.id", ondelete="CASCADE"), nullable=False),
204
+ sa.Column("command_type", sa.String(), nullable=False),
205
+ sa.Column("payload", JSONB, nullable=True),
206
+ sa.Column("issued_by", sa.String(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
207
+ sa.Column("status", sa.String(), server_default="'pending'"),
208
+ sa.Column("dispatched_at", sa.DateTime(timezone=True), nullable=True),
209
+ sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
210
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
211
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
212
+ )
213
+ op.create_index("ix_agent_commands_agent_status", "agent_commands", ["agent_id", "status"])
214
+ op.create_index("ix_agent_commands_expires", "agent_commands", ["expires_at"])
215
+
216
+
217
+ def downgrade() -> None:
218
+ # Drop all new tables
219
+ op.drop_table("agent_commands")
220
+ op.drop_table("agent_setup_tokens")
221
+ op.drop_table("agent_alert_history")
222
+ op.drop_table("agent_alert_configs")
223
+ op.drop_table("agent_events")
224
+ op.drop_table("agent_metrics_daily")
225
+ op.drop_table("agent_metrics_1h")
226
+ op.drop_table("agent_metrics_1m")
227
+
228
+ # Remove api_keys.agent_id
229
+ op.drop_index("ix_api_keys_agent_id", table_name="api_keys")
230
+ op.drop_column("api_keys", "agent_id")
231
+
232
+ # Remove agents columns
233
+ op.drop_index("ix_agents_user_state", table_name="agents")
234
+ op.drop_index("ix_agents_state", table_name="agents")
235
+ op.drop_column("agents", "deleted_at")
236
+ op.drop_column("agents", "last_error_message")
237
+ op.drop_column("agents", "last_error_at")
238
+ op.drop_column("agents", "last_restart_reason")
239
+ op.drop_column("agents", "last_restart_at")
240
+ op.drop_column("agents", "restart_count")
241
+ op.drop_column("agents", "first_connected_at")
242
+ op.drop_column("agents", "last_online_at")
243
+ op.drop_column("agents", "host_info")
244
+ op.drop_column("agents", "sdk_version")
245
+ op.drop_column("agents", "agent_type")
246
+ op.drop_column("agents", "state")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kairo-code
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Kairo Code - AI Coding Assistant by Kairon Labs
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown