codetether 1.2.2__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 (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. ui/monitor.js +2662 -0
@@ -0,0 +1,486 @@
1
+ """
2
+ Email Notifications for Hosted Workers
3
+
4
+ Sends task completion/failure emails via SendGrid.
5
+ Used by hosted_worker.py when tasks complete.
6
+
7
+ Configuration (environment variables):
8
+ - SENDGRID_API_KEY: SendGrid API key (required)
9
+ - SENDGRID_FROM_EMAIL: Sender email address (required)
10
+ - EMAIL_INBOUND_DOMAIN: Domain for reply-to addresses (optional)
11
+ - EMAIL_REPLY_PREFIX: Prefix for reply addresses, default "task" (optional)
12
+ """
13
+
14
+ import html as html_module
15
+ import json
16
+ import logging
17
+ import os
18
+ from typing import Any, Dict, Optional
19
+
20
+ import httpx
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # SendGrid API endpoint
25
+ SENDGRID_API_URL = 'https://api.sendgrid.com/v3/mail/send'
26
+
27
+
28
+ def _get_config() -> Dict[str, Any]:
29
+ """Get email configuration from environment."""
30
+ return {
31
+ 'api_key': os.environ.get('SENDGRID_API_KEY', ''),
32
+ 'from_email': os.environ.get('SENDGRID_FROM_EMAIL', ''),
33
+ 'inbound_domain': os.environ.get('EMAIL_INBOUND_DOMAIN', ''),
34
+ 'reply_prefix': os.environ.get('EMAIL_REPLY_PREFIX', 'task'),
35
+ }
36
+
37
+
38
+ def is_configured() -> bool:
39
+ """Check if email notifications are properly configured."""
40
+ config = _get_config()
41
+ return bool(config['api_key'] and config['from_email'])
42
+
43
+
44
+ def build_reply_to_address(
45
+ session_id: str,
46
+ codebase_id: Optional[str] = None,
47
+ ) -> Optional[str]:
48
+ """
49
+ Build reply-to address for email continuation.
50
+
51
+ Format: {prefix}+{session_id}@{domain}
52
+ Or: {prefix}+{session_id}+{codebase_id}@{domain}
53
+ """
54
+ config = _get_config()
55
+ if not config['inbound_domain']:
56
+ return None
57
+
58
+ prefix = config['reply_prefix']
59
+ domain = config['inbound_domain']
60
+
61
+ if codebase_id:
62
+ return f'{prefix}+{session_id}+{codebase_id}@{domain}'
63
+ return f'{prefix}+{session_id}@{domain}'
64
+
65
+
66
+ def _format_runtime(seconds: Optional[int]) -> str:
67
+ """Format runtime seconds as human-readable string."""
68
+ if not seconds:
69
+ return 'N/A'
70
+
71
+ minutes = seconds // 60
72
+ remaining_seconds = seconds % 60
73
+
74
+ if minutes > 0:
75
+ return f'{minutes}m {remaining_seconds}s'
76
+ return f'{seconds}s'
77
+
78
+
79
+ def _sanitize_result(result: Optional[str], max_length: int = 3000) -> str:
80
+ """
81
+ Sanitize and format result for HTML email.
82
+
83
+ Handles NDJSON streaming output, extracts text content,
84
+ escapes HTML, and truncates to max length.
85
+ """
86
+ if not result:
87
+ return ''
88
+
89
+ text_parts = []
90
+
91
+ try:
92
+ lines = result.strip().split('\n')
93
+ for line in lines:
94
+ line = line.strip()
95
+ if not line:
96
+ continue
97
+ try:
98
+ parsed = json.loads(line)
99
+ if isinstance(parsed, dict):
100
+ # OpenCode streaming format
101
+ event_type = parsed.get('type', '')
102
+ part = parsed.get('part', {})
103
+
104
+ if event_type == 'text' and isinstance(part, dict):
105
+ text = part.get('text', '')
106
+ if text:
107
+ text_parts.append(text)
108
+ elif 'text' in parsed and isinstance(parsed['text'], str):
109
+ text_parts.append(parsed['text'])
110
+ elif 'result' in parsed:
111
+ text_parts.append(str(parsed['result']))
112
+ elif 'output' in parsed:
113
+ text_parts.append(str(parsed['output']))
114
+ elif 'message' in parsed and isinstance(
115
+ parsed['message'], str
116
+ ):
117
+ text_parts.append(parsed['message'])
118
+ except json.JSONDecodeError:
119
+ if line and not line.startswith('{'):
120
+ text_parts.append(line)
121
+
122
+ if text_parts:
123
+ display_result = ' '.join(text_parts)
124
+ else:
125
+ # Try parsing as single JSON
126
+ try:
127
+ parsed = json.loads(result)
128
+ if isinstance(parsed, dict):
129
+ for key in [
130
+ 'result',
131
+ 'output',
132
+ 'message',
133
+ 'content',
134
+ 'response',
135
+ 'text',
136
+ ]:
137
+ if key in parsed:
138
+ display_result = str(parsed[key])
139
+ break
140
+ else:
141
+ display_result = result
142
+ else:
143
+ display_result = result
144
+ except json.JSONDecodeError:
145
+ display_result = result
146
+ except Exception:
147
+ display_result = result
148
+
149
+ # Escape HTML and truncate
150
+ display_result = html_module.escape(display_result)
151
+ display_result = display_result.replace('\n', '<br>')
152
+
153
+ if len(display_result) > max_length:
154
+ display_result = display_result[:max_length] + '...'
155
+
156
+ return display_result
157
+
158
+
159
+ def _build_email_html(
160
+ task_id: str,
161
+ title: str,
162
+ status: str,
163
+ result: Optional[str] = None,
164
+ error: Optional[str] = None,
165
+ runtime_seconds: Optional[int] = None,
166
+ session_id: Optional[str] = None,
167
+ reply_enabled: bool = False,
168
+ worker_name: str = 'Hosted Worker',
169
+ ) -> str:
170
+ """Build HTML email body for task completion."""
171
+ status_color = '#22c55e' if status == 'completed' else '#ef4444'
172
+ status_icon = '✓' if status == 'completed' else '✗'
173
+ duration_str = _format_runtime(runtime_seconds)
174
+
175
+ # Result section
176
+ result_section = ''
177
+ if result and status == 'completed':
178
+ sanitized = _sanitize_result(result)
179
+ if sanitized:
180
+ result_section = f"""
181
+ <tr>
182
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; width: 140px; vertical-align: top;">Output</td>
183
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">
184
+ <div style="font-size: 14px; line-height: 1.6; color: #1f2937;">{sanitized}</div>
185
+ </td>
186
+ </tr>"""
187
+
188
+ # Error section
189
+ error_section = ''
190
+ if error:
191
+ truncated = html_module.escape(
192
+ error[:1000] + '...' if len(error) > 1000 else error
193
+ )
194
+ error_section = f"""
195
+ <tr>
196
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; width: 140px;">Error</td>
197
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">
198
+ <pre style="margin: 0; white-space: pre-wrap; word-break: break-word; font-family: monospace; font-size: 13px; background: #fef2f2; padding: 12px; border-radius: 6px; color: #dc2626;">{truncated}</pre>
199
+ </td>
200
+ </tr>"""
201
+
202
+ # Footer
203
+ if reply_enabled:
204
+ footer_html = f"""
205
+ <div style="background: #f9fafb; padding: 16px; text-align: center;">
206
+ <p style="margin: 0 0 8px 0; font-size: 13px; color: #374151; font-weight: 500;">
207
+ Reply to this email to continue the conversation
208
+ </p>
209
+ <p style="margin: 0; font-size: 12px; color: #6b7280;">
210
+ Your reply will be sent to the agent to continue working on this task.
211
+ </p>
212
+ <p style="margin: 8px 0 0 0; font-size: 11px; color: #9ca3af;">
213
+ Sent by CodeTether - {worker_name}
214
+ </p>
215
+ </div>"""
216
+ else:
217
+ footer_html = f"""
218
+ <div style="background: #f9fafb; padding: 16px; text-align: center; font-size: 12px; color: #6b7280;">
219
+ Sent by CodeTether - {worker_name}
220
+ </div>"""
221
+
222
+ return f"""
223
+ <!DOCTYPE html>
224
+ <html>
225
+ <head>
226
+ <meta charset="utf-8">
227
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
228
+ </head>
229
+ <body style="margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
230
+ <div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
231
+ <div style="background: linear-gradient(135deg, #1e293b 0%, #334155 100%); padding: 24px; text-align: center;">
232
+ <h1 style="margin: 0; color: white; font-size: 20px; font-weight: 600;">Task Report</h1>
233
+ </div>
234
+ <div style="padding: 24px;">
235
+ <div style="display: inline-block; padding: 6px 12px; border-radius: 20px; background: {status_color}20; color: {status_color}; font-weight: 600; font-size: 14px; margin-bottom: 16px;">
236
+ {status_icon} {status.upper()}
237
+ </div>
238
+ <table style="width: 100%; border-collapse: collapse; margin-top: 16px;">
239
+ <tr>
240
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; width: 140px;">Task</td>
241
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">{html_module.escape(title)}</td>
242
+ </tr>
243
+ <tr>
244
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151;">Task ID</td>
245
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-family: monospace; font-size: 13px;">{task_id}</td>
246
+ </tr>
247
+ <tr>
248
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151;">Duration</td>
249
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">{duration_str}</td>
250
+ </tr>
251
+ {result_section}
252
+ {error_section}
253
+ </table>
254
+ </div>
255
+ {footer_html}
256
+ </div>
257
+ </body>
258
+ </html>"""
259
+
260
+
261
+ async def send_task_completion_email(
262
+ to_email: str,
263
+ task_id: str,
264
+ title: str,
265
+ status: str,
266
+ result: Optional[str] = None,
267
+ error: Optional[str] = None,
268
+ runtime_seconds: Optional[int] = None,
269
+ session_id: Optional[str] = None,
270
+ codebase_id: Optional[str] = None,
271
+ worker_name: str = 'Hosted Worker',
272
+ ) -> bool:
273
+ """
274
+ Send a task completion email via SendGrid.
275
+
276
+ Args:
277
+ to_email: Recipient email address
278
+ task_id: ID of the completed task
279
+ title: Task title
280
+ status: Task status ('completed' or 'failed')
281
+ result: Task result/output (optional)
282
+ error: Error message if failed (optional)
283
+ runtime_seconds: Task runtime in seconds (optional)
284
+ session_id: Session ID for reply continuation (optional)
285
+ codebase_id: Codebase ID for reply continuation (optional)
286
+ worker_name: Worker name for email footer
287
+
288
+ Returns:
289
+ True if email sent successfully, False otherwise
290
+ """
291
+ config = _get_config()
292
+
293
+ if not config['api_key'] or not config['from_email']:
294
+ logger.warning(
295
+ 'Email not configured (missing SENDGRID_API_KEY or SENDGRID_FROM_EMAIL)'
296
+ )
297
+ return False
298
+
299
+ # Build reply-to address if session_id is provided
300
+ reply_to = None
301
+ reply_enabled = False
302
+ if session_id and config['inbound_domain']:
303
+ reply_to = build_reply_to_address(session_id, codebase_id)
304
+ reply_enabled = True
305
+
306
+ # Build email content
307
+ html_body = _build_email_html(
308
+ task_id=task_id,
309
+ title=title,
310
+ status=status,
311
+ result=result,
312
+ error=error,
313
+ runtime_seconds=runtime_seconds,
314
+ session_id=session_id,
315
+ reply_enabled=reply_enabled,
316
+ worker_name=worker_name,
317
+ )
318
+
319
+ # Conversion-optimized subject lines
320
+ if status == 'completed':
321
+ subject = f'✅ Done: {title}'
322
+ elif status == 'failed':
323
+ subject = f'❌ Failed: {title} (reply for a fix)'
324
+ else:
325
+ subject = f'[CodeTether] Task {status}: {title}'
326
+
327
+ # Build SendGrid payload
328
+ payload = {
329
+ 'personalizations': [{'to': [{'email': to_email}]}],
330
+ 'from': {'email': config['from_email']},
331
+ 'subject': subject,
332
+ 'content': [{'type': 'text/html', 'value': html_body}],
333
+ }
334
+
335
+ if reply_to:
336
+ payload['reply_to'] = {'email': reply_to}
337
+
338
+ # Send via SendGrid
339
+ try:
340
+ async with httpx.AsyncClient(timeout=30.0) as client:
341
+ response = await client.post(
342
+ SENDGRID_API_URL,
343
+ json=payload,
344
+ headers={
345
+ 'Authorization': f'Bearer {config["api_key"]}',
346
+ 'Content-Type': 'application/json',
347
+ },
348
+ )
349
+
350
+ if response.status_code in (200, 202):
351
+ logger.info(f'Email sent to {to_email} for task {task_id}')
352
+ return True
353
+ else:
354
+ logger.error(
355
+ f'SendGrid error {response.status_code}: {response.text}'
356
+ )
357
+ return False
358
+
359
+ except Exception as e:
360
+ logger.error(f'Failed to send email: {e}')
361
+ return False
362
+
363
+
364
+ async def send_task_question_email(
365
+ to_email: str,
366
+ task_id: str,
367
+ title: str,
368
+ question: str,
369
+ session_id: str,
370
+ codebase_id: Optional[str] = None,
371
+ worker_name: str = 'Hosted Worker',
372
+ ) -> bool:
373
+ """
374
+ Send a "needs input" email when task requires user response.
375
+
376
+ Args:
377
+ to_email: Recipient email address
378
+ task_id: ID of the task needing input
379
+ title: Task title
380
+ question: The question/input needed from user
381
+ session_id: Session ID for reply (required for question emails)
382
+ codebase_id: Codebase ID for reply continuation
383
+ worker_name: Worker name for email footer
384
+
385
+ Returns:
386
+ True if email sent successfully, False otherwise
387
+ """
388
+ config = _get_config()
389
+
390
+ if not config['api_key'] or not config['from_email']:
391
+ logger.warning('Email not configured')
392
+ return False
393
+
394
+ if not config['inbound_domain']:
395
+ logger.warning(
396
+ 'Cannot send question email without EMAIL_INBOUND_DOMAIN configured'
397
+ )
398
+ return False
399
+
400
+ reply_to = build_reply_to_address(session_id, codebase_id)
401
+
402
+ # Build question email HTML
403
+ question_escaped = html_module.escape(question).replace('\n', '<br>')
404
+
405
+ html_body = f"""
406
+ <!DOCTYPE html>
407
+ <html>
408
+ <head>
409
+ <meta charset="utf-8">
410
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
411
+ </head>
412
+ <body style="margin: 0; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f3f4f6;">
413
+ <div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
414
+ <div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 24px; text-align: center;">
415
+ <h1 style="margin: 0; color: white; font-size: 20px; font-weight: 600;">Input Needed</h1>
416
+ </div>
417
+ <div style="padding: 24px;">
418
+ <div style="display: inline-block; padding: 6px 12px; border-radius: 20px; background: #fef3c720; color: #d97706; font-weight: 600; font-size: 14px; margin-bottom: 16px;">
419
+ ⏳ WAITING FOR INPUT
420
+ </div>
421
+ <table style="width: 100%; border-collapse: collapse; margin-top: 16px;">
422
+ <tr>
423
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; width: 140px;">Task</td>
424
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">{html_module.escape(title)}</td>
425
+ </tr>
426
+ <tr>
427
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #374151; vertical-align: top;">Question</td>
428
+ <td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">
429
+ <div style="background: #fffbeb; padding: 16px; border-radius: 8px; border-left: 4px solid #f59e0b;">
430
+ <p style="margin: 0; font-size: 15px; line-height: 1.6; color: #1f2937;">{question_escaped}</p>
431
+ </div>
432
+ </td>
433
+ </tr>
434
+ </table>
435
+ </div>
436
+ <div style="background: #f9fafb; padding: 16px; text-align: center;">
437
+ <p style="margin: 0 0 8px 0; font-size: 14px; color: #374151; font-weight: 600;">
438
+ Reply to this email with your answer
439
+ </p>
440
+ <p style="margin: 0; font-size: 12px; color: #6b7280;">
441
+ Your response will be sent to the agent to continue the task.
442
+ </p>
443
+ <p style="margin: 8px 0 0 0; font-size: 11px; color: #9ca3af;">
444
+ Sent by CodeTether - {worker_name}
445
+ </p>
446
+ </div>
447
+ </div>
448
+ </body>
449
+ </html>"""
450
+
451
+ # Conversion-optimized subject line for questions
452
+ subject = f'❓ Question: {title} (reply to continue)'
453
+
454
+ payload = {
455
+ 'personalizations': [{'to': [{'email': to_email}]}],
456
+ 'from': {'email': config['from_email']},
457
+ 'reply_to': {'email': reply_to},
458
+ 'subject': subject,
459
+ 'content': [{'type': 'text/html', 'value': html_body}],
460
+ }
461
+
462
+ try:
463
+ async with httpx.AsyncClient(timeout=30.0) as client:
464
+ response = await client.post(
465
+ SENDGRID_API_URL,
466
+ json=payload,
467
+ headers={
468
+ 'Authorization': f'Bearer {config["api_key"]}',
469
+ 'Content-Type': 'application/json',
470
+ },
471
+ )
472
+
473
+ if response.status_code in (200, 202):
474
+ logger.info(
475
+ f'Question email sent to {to_email} for task {task_id}'
476
+ )
477
+ return True
478
+ else:
479
+ logger.error(
480
+ f'SendGrid error {response.status_code}: {response.text}'
481
+ )
482
+ return False
483
+
484
+ except Exception as e:
485
+ logger.error(f'Failed to send question email: {e}')
486
+ return False