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.
- kairo/backend/api/agents.py +337 -16
- kairo/backend/app.py +84 -4
- kairo/backend/config.py +4 -2
- kairo/backend/models/agent.py +216 -2
- kairo/backend/models/api_key.py +4 -1
- kairo/backend/models/task.py +31 -0
- kairo/backend/models/user_provider_key.py +26 -0
- kairo/backend/schemas/agent.py +249 -2
- kairo/backend/schemas/api_key.py +3 -0
- kairo/backend/services/agent/__init__.py +52 -0
- kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
- kairo/backend/services/agent/agent_alerts_service.py +201 -0
- kairo/backend/services/agent/agent_commands_service.py +142 -0
- kairo/backend/services/agent/agent_crud_service.py +150 -0
- kairo/backend/services/agent/agent_events_service.py +103 -0
- kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
- kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
- kairo/backend/services/agent/agent_metrics_service.py +259 -0
- kairo/backend/services/agent/agent_service.py +315 -0
- kairo/backend/services/agent/agent_setup_service.py +180 -0
- kairo/backend/services/agent/constants.py +28 -0
- kairo/backend/services/agent_service.py +18 -102
- kairo/backend/services/api_key_service.py +23 -3
- kairo/backend/services/byok_service.py +204 -0
- kairo/backend/services/chat_service.py +398 -63
- kairo/backend/services/deep_search_service.py +159 -0
- kairo/backend/services/email_service.py +418 -19
- kairo/backend/services/few_shot_service.py +223 -0
- kairo/backend/services/post_processor.py +261 -0
- kairo/backend/services/rag_service.py +150 -0
- kairo/backend/services/task_service.py +119 -0
- kairo/backend/tests/__init__.py +1 -0
- kairo/backend/tests/e2e/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/conftest.py +389 -0
- kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
- kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
- kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
- kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
- kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
- kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
- kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
- kairo/migrations/versions/010_agent_dashboard.py +246 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
- kairo_migrations/env.py +92 -0
- kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
- {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")
|