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,450 @@
1
+ """
2
+ Add agent dashboard extensions
3
+
4
+ Revision ID: 001_agent_dashboard
5
+ Revises: (previous migration)
6
+ Create Date: 2026-02-03
7
+
8
+ This migration extends the existing agents table and creates new tables
9
+ for the Agent Dashboard feature.
10
+ """
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ from sqlalchemy.dialects import postgresql
15
+
16
+ # revision identifiers
17
+ revision = '001_agent_dashboard'
18
+ down_revision = None # Set to previous migration ID
19
+ branch_labels = None
20
+ depends_on = None
21
+
22
+
23
+ def upgrade() -> None:
24
+ # =========================================================================
25
+ # STEP 1: Create ENUM types
26
+ # =========================================================================
27
+
28
+ # Create enum types (if they don't exist)
29
+ agent_state_enum = postgresql.ENUM(
30
+ 'created', 'online', 'idle', 'busy', 'offline', 'stale', 'disabled',
31
+ name='agent_state',
32
+ create_type=False,
33
+ )
34
+ agent_type_enum = postgresql.ENUM(
35
+ 'sdk', 'container', 'simple',
36
+ name='agent_type',
37
+ create_type=False,
38
+ )
39
+ agent_event_type_enum = postgresql.ENUM(
40
+ 'heartbeat', 'connected', 'disconnected', 'restart_requested',
41
+ 'restart_completed', 'error', 'config_changed', 'state_changed',
42
+ 'task_started', 'task_completed',
43
+ name='agent_event_type',
44
+ create_type=False,
45
+ )
46
+ alert_severity_enum = postgresql.ENUM(
47
+ 'info', 'warning', 'error', 'critical',
48
+ name='alert_severity',
49
+ create_type=False,
50
+ )
51
+ alert_channel_enum = postgresql.ENUM(
52
+ 'email', 'webhook', 'slack', 'pagerduty', 'in_app',
53
+ name='alert_channel',
54
+ create_type=False,
55
+ )
56
+ alert_status_enum = postgresql.ENUM(
57
+ 'triggered', 'acknowledged', 'resolved', 'expired',
58
+ name='alert_status',
59
+ create_type=False,
60
+ )
61
+ setup_token_status_enum = postgresql.ENUM(
62
+ 'pending', 'used', 'expired', 'revoked',
63
+ name='setup_token_status',
64
+ create_type=False,
65
+ )
66
+
67
+ # Create the enum types in the database
68
+ op.execute("CREATE TYPE agent_state AS ENUM ('created', 'online', 'idle', 'busy', 'offline', 'stale', 'disabled')")
69
+ op.execute("CREATE TYPE agent_type AS ENUM ('sdk', 'container', 'simple')")
70
+ op.execute("CREATE TYPE agent_event_type AS ENUM ('heartbeat', 'connected', 'disconnected', 'restart_requested', 'restart_completed', 'error', 'config_changed', 'state_changed', 'task_started', 'task_completed')")
71
+ op.execute("CREATE TYPE alert_severity AS ENUM ('info', 'warning', 'error', 'critical')")
72
+ op.execute("CREATE TYPE alert_channel AS ENUM ('email', 'webhook', 'slack', 'pagerduty', 'in_app')")
73
+ op.execute("CREATE TYPE alert_status AS ENUM ('triggered', 'acknowledged', 'resolved', 'expired')")
74
+ op.execute("CREATE TYPE setup_token_status AS ENUM ('pending', 'used', 'expired', 'revoked')")
75
+
76
+ # =========================================================================
77
+ # STEP 2: Extend existing agents table
78
+ # =========================================================================
79
+
80
+ # Add new columns to agents table
81
+ op.add_column('agents', sa.Column('state', agent_state_enum, nullable=True))
82
+ op.add_column('agents', sa.Column('previous_state', agent_state_enum, nullable=True))
83
+ op.add_column('agents', sa.Column('state_changed_at', sa.DateTime(timezone=True), nullable=True))
84
+ op.add_column('agents', sa.Column('last_heartbeat_at', sa.DateTime(timezone=True), nullable=True))
85
+ op.add_column('agents', sa.Column('last_online_at', sa.DateTime(timezone=True), nullable=True))
86
+ op.add_column('agents', sa.Column('first_connected_at', sa.DateTime(timezone=True), nullable=True))
87
+ op.add_column('agents', sa.Column('agent_type', agent_type_enum, nullable=True))
88
+ op.add_column('agents', sa.Column('sdk_version', sa.String(32), nullable=True))
89
+ op.add_column('agents', sa.Column('host_info', postgresql.JSONB, nullable=True))
90
+ op.add_column('agents', sa.Column('restart_count', sa.Integer, nullable=True, server_default='0'))
91
+ op.add_column('agents', sa.Column('last_restart_at', sa.DateTime(timezone=True), nullable=True))
92
+ op.add_column('agents', sa.Column('last_restart_reason', sa.String(255), nullable=True))
93
+ op.add_column('agents', sa.Column('config', postgresql.JSONB, nullable=True, server_default='{}'))
94
+ op.add_column('agents', sa.Column('capabilities', postgresql.JSONB, nullable=True, server_default='[]'))
95
+ op.add_column('agents', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
96
+
97
+ # Set default values for existing rows
98
+ op.execute("UPDATE agents SET state = 'created' WHERE state IS NULL")
99
+ op.execute("UPDATE agents SET agent_type = 'sdk' WHERE agent_type IS NULL")
100
+ op.execute("UPDATE agents SET restart_count = 0 WHERE restart_count IS NULL")
101
+
102
+ # Make columns NOT NULL after setting defaults
103
+ op.alter_column('agents', 'state', nullable=False)
104
+ op.alter_column('agents', 'agent_type', nullable=False)
105
+ op.alter_column('agents', 'restart_count', nullable=False)
106
+
107
+ # Create indexes on agents table
108
+ op.create_index(
109
+ 'idx_agents_state',
110
+ 'agents',
111
+ ['state'],
112
+ postgresql_where=sa.text('deleted_at IS NULL'),
113
+ )
114
+ op.create_index(
115
+ 'idx_agents_user_state',
116
+ 'agents',
117
+ ['user_id', 'state'],
118
+ postgresql_where=sa.text('deleted_at IS NULL'),
119
+ )
120
+ op.create_index(
121
+ 'idx_agents_last_heartbeat',
122
+ 'agents',
123
+ [sa.text('last_heartbeat_at DESC')],
124
+ postgresql_where=sa.text('deleted_at IS NULL'),
125
+ )
126
+ op.create_index(
127
+ 'idx_agents_agent_type',
128
+ 'agents',
129
+ ['agent_type'],
130
+ postgresql_where=sa.text('deleted_at IS NULL'),
131
+ )
132
+ op.create_index(
133
+ 'idx_agents_active',
134
+ 'agents',
135
+ ['user_id', sa.text('last_heartbeat_at DESC')],
136
+ postgresql_where=sa.text("state IN ('online', 'idle', 'busy') AND deleted_at IS NULL"),
137
+ )
138
+
139
+ # =========================================================================
140
+ # STEP 3: Create agent_metrics_1m table
141
+ # =========================================================================
142
+
143
+ op.create_table(
144
+ 'agent_metrics_1m',
145
+ sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
146
+ sa.Column('agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id', ondelete='CASCADE'), nullable=False),
147
+ sa.Column('bucket_time', sa.DateTime(timezone=True), nullable=False),
148
+ sa.Column('request_count', sa.Integer, nullable=False, server_default='0'),
149
+ sa.Column('error_count', sa.Integer, nullable=False, server_default='0'),
150
+ sa.Column('timeout_count', sa.Integer, nullable=False, server_default='0'),
151
+ sa.Column('input_tokens', sa.BigInteger, nullable=False, server_default='0'),
152
+ sa.Column('output_tokens', sa.BigInteger, nullable=False, server_default='0'),
153
+ sa.Column('latency_sum_ms', sa.BigInteger, nullable=False, server_default='0'),
154
+ sa.Column('latency_count', sa.Integer, nullable=False, server_default='0'),
155
+ sa.Column('latency_min_ms', sa.Integer, nullable=True),
156
+ sa.Column('latency_max_ms', sa.Integer, nullable=True),
157
+ sa.Column('latency_p50_ms', sa.Integer, nullable=True),
158
+ sa.Column('latency_p99_ms', sa.Integer, nullable=True),
159
+ sa.Column('tool_call_count', sa.Integer, nullable=False, server_default='0'),
160
+ sa.Column('tool_calls_by_name', postgresql.JSONB, nullable=False, server_default='{}'),
161
+ sa.Column('model_usage', postgresql.JSONB, nullable=False, server_default='{}'),
162
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
163
+ )
164
+
165
+ op.create_index('idx_metrics_1m_agent_time', 'agent_metrics_1m', ['agent_id', 'bucket_time'])
166
+ op.create_index('idx_metrics_1m_time', 'agent_metrics_1m', [sa.text('bucket_time DESC')])
167
+ op.create_unique_constraint('idx_metrics_1m_agent_bucket', 'agent_metrics_1m', ['agent_id', 'bucket_time'])
168
+
169
+ # =========================================================================
170
+ # STEP 4: Create agent_metrics_1h table
171
+ # =========================================================================
172
+
173
+ op.create_table(
174
+ 'agent_metrics_1h',
175
+ sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
176
+ sa.Column('agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id', ondelete='CASCADE'), nullable=False),
177
+ sa.Column('bucket_time', sa.DateTime(timezone=True), nullable=False),
178
+ sa.Column('request_count', sa.Integer, nullable=False, server_default='0'),
179
+ sa.Column('error_count', sa.Integer, nullable=False, server_default='0'),
180
+ sa.Column('timeout_count', sa.Integer, nullable=False, server_default='0'),
181
+ sa.Column('input_tokens', sa.BigInteger, nullable=False, server_default='0'),
182
+ sa.Column('output_tokens', sa.BigInteger, nullable=False, server_default='0'),
183
+ sa.Column('latency_sum_ms', sa.BigInteger, nullable=False, server_default='0'),
184
+ sa.Column('latency_count', sa.Integer, nullable=False, server_default='0'),
185
+ sa.Column('latency_min_ms', sa.Integer, nullable=True),
186
+ sa.Column('latency_max_ms', sa.Integer, nullable=True),
187
+ sa.Column('latency_p50_ms', sa.Integer, nullable=True),
188
+ sa.Column('latency_p99_ms', sa.Integer, nullable=True),
189
+ sa.Column('tool_call_count', sa.Integer, nullable=False, server_default='0'),
190
+ sa.Column('tool_calls_by_name', postgresql.JSONB, nullable=False, server_default='{}'),
191
+ sa.Column('model_usage', postgresql.JSONB, nullable=False, server_default='{}'),
192
+ sa.Column('source_bucket_count', sa.Integer, nullable=False, server_default='0'),
193
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
194
+ )
195
+
196
+ op.create_index('idx_metrics_1h_agent_time', 'agent_metrics_1h', ['agent_id', 'bucket_time'])
197
+ op.create_index('idx_metrics_1h_time', 'agent_metrics_1h', [sa.text('bucket_time DESC')])
198
+ op.create_unique_constraint('idx_metrics_1h_agent_bucket', 'agent_metrics_1h', ['agent_id', 'bucket_time'])
199
+
200
+ # =========================================================================
201
+ # STEP 5: Create agent_metrics_daily table
202
+ # =========================================================================
203
+
204
+ op.create_table(
205
+ 'agent_metrics_daily',
206
+ sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
207
+ sa.Column('agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id', ondelete='CASCADE'), nullable=False),
208
+ sa.Column('bucket_date', sa.Date, nullable=False),
209
+ sa.Column('total_requests', sa.Integer, nullable=False, server_default='0'),
210
+ sa.Column('total_errors', sa.Integer, nullable=False, server_default='0'),
211
+ sa.Column('error_rate', sa.Numeric(5, 4), nullable=True),
212
+ sa.Column('total_input_tokens', sa.BigInteger, nullable=False, server_default='0'),
213
+ sa.Column('total_output_tokens', sa.BigInteger, nullable=False, server_default='0'),
214
+ sa.Column('avg_latency_ms', sa.Integer, nullable=True),
215
+ sa.Column('p50_latency_ms', sa.Integer, nullable=True),
216
+ sa.Column('p99_latency_ms', sa.Integer, nullable=True),
217
+ sa.Column('total_tool_calls', sa.Integer, nullable=False, server_default='0'),
218
+ sa.Column('most_used_tools', postgresql.JSONB, nullable=False, server_default='[]'),
219
+ sa.Column('uptime_minutes', sa.Integer, nullable=False, server_default='0'),
220
+ sa.Column('uptime_percentage', sa.Numeric(5, 2), nullable=True),
221
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
222
+ )
223
+
224
+ op.create_unique_constraint('idx_metrics_daily_agent_date', 'agent_metrics_daily', ['agent_id', 'bucket_date'])
225
+ op.create_index('idx_metrics_daily_date', 'agent_metrics_daily', [sa.text('bucket_date DESC')])
226
+
227
+ # =========================================================================
228
+ # STEP 6: Create agent_events table
229
+ # =========================================================================
230
+
231
+ op.create_table(
232
+ 'agent_events',
233
+ sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
234
+ sa.Column('agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id', ondelete='CASCADE'), nullable=False),
235
+ sa.Column('event_type', agent_event_type_enum, nullable=False),
236
+ sa.Column('event_time', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
237
+ sa.Column('payload', postgresql.JSONB, nullable=True),
238
+ sa.Column('error_type', sa.String(64), nullable=True),
239
+ sa.Column('error_message', sa.Text, nullable=True),
240
+ sa.Column('severity', alert_severity_enum, nullable=True),
241
+ sa.Column('session_id', postgresql.UUID(as_uuid=True), nullable=True),
242
+ )
243
+
244
+ op.create_index('idx_events_agent_time', 'agent_events', ['agent_id', sa.text('event_time DESC')])
245
+ op.create_index('idx_events_type_time', 'agent_events', ['event_type', sa.text('event_time DESC')])
246
+ op.create_index('idx_events_agent_type', 'agent_events', ['agent_id', 'event_type', sa.text('event_time DESC')])
247
+ op.create_index(
248
+ 'idx_events_session',
249
+ 'agent_events',
250
+ ['session_id', 'event_time'],
251
+ postgresql_where=sa.text('session_id IS NOT NULL'),
252
+ )
253
+ op.create_index(
254
+ 'idx_events_errors',
255
+ 'agent_events',
256
+ ['agent_id', sa.text('event_time DESC')],
257
+ postgresql_where=sa.text("event_type = 'error'"),
258
+ )
259
+ op.create_index('idx_events_payload', 'agent_events', ['payload'], postgresql_using='gin')
260
+
261
+ # =========================================================================
262
+ # STEP 7: Create agent_alert_configs table
263
+ # =========================================================================
264
+
265
+ op.create_table(
266
+ 'agent_alert_configs',
267
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')),
268
+ sa.Column('agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id', ondelete='CASCADE'), nullable=True),
269
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
270
+ sa.Column('name', sa.String(128), nullable=False),
271
+ sa.Column('description', sa.Text, nullable=True),
272
+ sa.Column('enabled', sa.Boolean, nullable=False, server_default='true'),
273
+ sa.Column('metric_type', sa.String(64), nullable=False),
274
+ sa.Column('condition_operator', sa.String(8), nullable=False),
275
+ sa.Column('threshold_value', sa.Numeric, nullable=False),
276
+ sa.Column('threshold_unit', sa.String(16), nullable=True),
277
+ sa.Column('evaluation_window_minutes', sa.Integer, nullable=False, server_default='5'),
278
+ sa.Column('min_samples', sa.Integer, nullable=False, server_default='1'),
279
+ sa.Column('channels', postgresql.ARRAY(sa.String), nullable=False, server_default="'{in_app}'"),
280
+ sa.Column('notification_config', postgresql.JSONB, nullable=False, server_default='{}'),
281
+ sa.Column('cooldown_minutes', sa.Integer, nullable=False, server_default='15'),
282
+ sa.Column('severity', alert_severity_enum, nullable=False, server_default="'warning'"),
283
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
284
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
285
+ sa.CheckConstraint("condition_operator IN ('gt', 'lt', 'gte', 'lte', 'eq')", name='valid_operator'),
286
+ )
287
+
288
+ op.create_index(
289
+ 'idx_alert_configs_agent',
290
+ 'agent_alert_configs',
291
+ ['agent_id'],
292
+ postgresql_where=sa.text('enabled = TRUE'),
293
+ )
294
+ op.create_index(
295
+ 'idx_alert_configs_user',
296
+ 'agent_alert_configs',
297
+ ['user_id'],
298
+ postgresql_where=sa.text('enabled = TRUE'),
299
+ )
300
+ op.create_index('idx_alert_configs_metric', 'agent_alert_configs', ['metric_type', 'enabled'])
301
+
302
+ # =========================================================================
303
+ # STEP 8: Create agent_alerts table
304
+ # =========================================================================
305
+
306
+ op.create_table(
307
+ 'agent_alerts',
308
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')),
309
+ sa.Column('config_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agent_alert_configs.id', ondelete='CASCADE'), nullable=False),
310
+ sa.Column('agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id', ondelete='CASCADE'), nullable=False),
311
+ sa.Column('status', alert_status_enum, nullable=False, server_default="'triggered'"),
312
+ sa.Column('severity', alert_severity_enum, nullable=False),
313
+ sa.Column('triggered_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
314
+ sa.Column('triggered_value', sa.Numeric, nullable=False),
315
+ sa.Column('threshold_value', sa.Numeric, nullable=False),
316
+ sa.Column('title', sa.String(255), nullable=False),
317
+ sa.Column('message', sa.Text, nullable=True),
318
+ sa.Column('details', postgresql.JSONB, nullable=True),
319
+ sa.Column('acknowledged_at', sa.DateTime(timezone=True), nullable=True),
320
+ sa.Column('acknowledged_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
321
+ sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
322
+ sa.Column('resolved_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
323
+ sa.Column('resolution_notes', sa.Text, nullable=True),
324
+ sa.Column('auto_resolved', sa.Boolean, nullable=False, server_default='false'),
325
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
326
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
327
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
328
+ )
329
+
330
+ op.create_index('idx_alerts_agent_status', 'agent_alerts', ['agent_id', 'status', sa.text('triggered_at DESC')])
331
+ op.create_index('idx_alerts_status_time', 'agent_alerts', ['status', sa.text('triggered_at DESC')])
332
+ op.create_index('idx_alerts_config', 'agent_alerts', ['config_id', sa.text('triggered_at DESC')])
333
+ op.create_index(
334
+ 'idx_alerts_active',
335
+ 'agent_alerts',
336
+ ['agent_id', sa.text('triggered_at DESC')],
337
+ postgresql_where=sa.text("status IN ('triggered', 'acknowledged')"),
338
+ )
339
+
340
+ # =========================================================================
341
+ # STEP 9: Create agent_setup_tokens table
342
+ # =========================================================================
343
+
344
+ op.create_table(
345
+ 'agent_setup_tokens',
346
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')),
347
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
348
+ sa.Column('agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id', ondelete='CASCADE'), nullable=True),
349
+ sa.Column('token_hash', sa.String(128), nullable=False),
350
+ sa.Column('token_prefix', sa.String(8), nullable=False),
351
+ sa.Column('name', sa.String(128), nullable=True),
352
+ sa.Column('status', setup_token_status_enum, nullable=False, server_default="'pending'"),
353
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
354
+ sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
355
+ sa.Column('used_from_ip', postgresql.INET, nullable=True),
356
+ sa.Column('used_agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id'), nullable=True),
357
+ sa.Column('initial_config', postgresql.JSONB, nullable=False, server_default='{}'),
358
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
359
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
360
+ )
361
+
362
+ op.create_index('idx_setup_tokens_prefix', 'agent_setup_tokens', ['token_prefix'])
363
+ op.create_index('idx_setup_tokens_user_status', 'agent_setup_tokens', ['user_id', 'status'])
364
+ op.create_index(
365
+ 'idx_setup_tokens_agent',
366
+ 'agent_setup_tokens',
367
+ ['agent_id'],
368
+ postgresql_where=sa.text('agent_id IS NOT NULL'),
369
+ )
370
+ op.create_index(
371
+ 'idx_setup_tokens_active',
372
+ 'agent_setup_tokens',
373
+ ['token_hash'],
374
+ postgresql_where=sa.text("status = 'pending'"),
375
+ )
376
+
377
+ # =========================================================================
378
+ # STEP 10: Create agent_pending_registrations table
379
+ # =========================================================================
380
+
381
+ op.create_table(
382
+ 'agent_pending_registrations',
383
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('uuid_generate_v4()')),
384
+ sa.Column('agent_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agents.id', ondelete='CASCADE'), nullable=False, unique=True),
385
+ sa.Column('setup_token_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('agent_setup_tokens.id', ondelete='SET NULL'), nullable=True),
386
+ sa.Column('expected_sdk_version', sa.String(32), nullable=True),
387
+ sa.Column('expected_capabilities', postgresql.ARRAY(sa.String), nullable=True),
388
+ sa.Column('wizard_step', sa.String(32), nullable=True),
389
+ sa.Column('wizard_data', postgresql.JSONB, nullable=False, server_default='{}'),
390
+ sa.Column('setup_instructions', sa.Text, nullable=True),
391
+ sa.Column('setup_command', sa.Text, nullable=True),
392
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
393
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
394
+ sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
395
+ )
396
+
397
+ op.create_index('idx_pending_reg_agent', 'agent_pending_registrations', ['agent_id'])
398
+ op.create_index(
399
+ 'idx_pending_reg_incomplete',
400
+ 'agent_pending_registrations',
401
+ ['created_at'],
402
+ postgresql_where=sa.text('completed_at IS NULL'),
403
+ )
404
+
405
+
406
+ def downgrade() -> None:
407
+ """Reverse the migration."""
408
+
409
+ # Drop tables in reverse order of creation
410
+ op.drop_table('agent_pending_registrations')
411
+ op.drop_table('agent_setup_tokens')
412
+ op.drop_table('agent_alerts')
413
+ op.drop_table('agent_alert_configs')
414
+ op.drop_table('agent_events')
415
+ op.drop_table('agent_metrics_daily')
416
+ op.drop_table('agent_metrics_1h')
417
+ op.drop_table('agent_metrics_1m')
418
+
419
+ # Drop indexes on agents table
420
+ op.drop_index('idx_agents_active', table_name='agents')
421
+ op.drop_index('idx_agents_agent_type', table_name='agents')
422
+ op.drop_index('idx_agents_last_heartbeat', table_name='agents')
423
+ op.drop_index('idx_agents_user_state', table_name='agents')
424
+ op.drop_index('idx_agents_state', table_name='agents')
425
+
426
+ # Drop columns from agents table
427
+ op.drop_column('agents', 'deleted_at')
428
+ op.drop_column('agents', 'capabilities')
429
+ op.drop_column('agents', 'config')
430
+ op.drop_column('agents', 'last_restart_reason')
431
+ op.drop_column('agents', 'last_restart_at')
432
+ op.drop_column('agents', 'restart_count')
433
+ op.drop_column('agents', 'host_info')
434
+ op.drop_column('agents', 'sdk_version')
435
+ op.drop_column('agents', 'agent_type')
436
+ op.drop_column('agents', 'first_connected_at')
437
+ op.drop_column('agents', 'last_online_at')
438
+ op.drop_column('agents', 'last_heartbeat_at')
439
+ op.drop_column('agents', 'state_changed_at')
440
+ op.drop_column('agents', 'previous_state')
441
+ op.drop_column('agents', 'state')
442
+
443
+ # Drop enum types
444
+ op.execute('DROP TYPE setup_token_status')
445
+ op.execute('DROP TYPE alert_status')
446
+ op.execute('DROP TYPE alert_channel')
447
+ op.execute('DROP TYPE alert_severity')
448
+ op.execute('DROP TYPE agent_event_type')
449
+ op.execute('DROP TYPE agent_type')
450
+ op.execute('DROP TYPE agent_state')