synapse-sdk 1.0.0a59__py3-none-any.whl → 1.0.0a61__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.

Potentially problematic release.


This version of synapse-sdk might be problematic. Click here for more details.

Files changed (87) hide show
  1. synapse_sdk/cli/__init__.py +246 -5
  2. synapse_sdk/cli/alias/utils.py +1 -1
  3. synapse_sdk/cli/config.py +339 -0
  4. synapse_sdk/cli/devtools.py +61 -0
  5. synapse_sdk/cli/plugin/publish.py +3 -4
  6. synapse_sdk/clients/agent/__init__.py +7 -2
  7. synapse_sdk/clients/agent/ray.py +37 -6
  8. synapse_sdk/clients/backend/__init__.py +5 -9
  9. synapse_sdk/clients/backend/annotation.py +4 -0
  10. synapse_sdk/clients/base.py +42 -3
  11. synapse_sdk/devtools/__init__.py +0 -0
  12. synapse_sdk/devtools/config.py +94 -0
  13. synapse_sdk/devtools/docs/.gitignore +20 -0
  14. synapse_sdk/devtools/docs/README.md +41 -0
  15. synapse_sdk/devtools/docs/blog/2019-05-28-first-blog-post.md +12 -0
  16. synapse_sdk/devtools/docs/blog/2019-05-29-long-blog-post.md +44 -0
  17. synapse_sdk/devtools/docs/blog/2021-08-01-mdx-blog-post.mdx +24 -0
  18. synapse_sdk/devtools/docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg +0 -0
  19. synapse_sdk/devtools/docs/blog/2021-08-26-welcome/index.md +29 -0
  20. synapse_sdk/devtools/docs/blog/authors.yml +25 -0
  21. synapse_sdk/devtools/docs/blog/tags.yml +19 -0
  22. synapse_sdk/devtools/docs/docusaurus.config.ts +138 -0
  23. synapse_sdk/devtools/docs/package-lock.json +17455 -0
  24. synapse_sdk/devtools/docs/package.json +47 -0
  25. synapse_sdk/devtools/docs/sidebars.ts +36 -0
  26. synapse_sdk/devtools/docs/src/components/HomepageFeatures/index.tsx +71 -0
  27. synapse_sdk/devtools/docs/src/components/HomepageFeatures/styles.module.css +11 -0
  28. synapse_sdk/devtools/docs/src/css/custom.css +30 -0
  29. synapse_sdk/devtools/docs/src/pages/index.module.css +23 -0
  30. synapse_sdk/devtools/docs/src/pages/index.tsx +21 -0
  31. synapse_sdk/devtools/docs/src/pages/markdown-page.md +7 -0
  32. synapse_sdk/devtools/docs/static/.nojekyll +0 -0
  33. synapse_sdk/devtools/docs/static/img/docusaurus-social-card.jpg +0 -0
  34. synapse_sdk/devtools/docs/static/img/docusaurus.png +0 -0
  35. synapse_sdk/devtools/docs/static/img/favicon.ico +0 -0
  36. synapse_sdk/devtools/docs/static/img/logo.png +0 -0
  37. synapse_sdk/devtools/docs/static/img/undraw_docusaurus_mountain.svg +171 -0
  38. synapse_sdk/devtools/docs/static/img/undraw_docusaurus_react.svg +170 -0
  39. synapse_sdk/devtools/docs/static/img/undraw_docusaurus_tree.svg +40 -0
  40. synapse_sdk/devtools/docs/tsconfig.json +8 -0
  41. synapse_sdk/devtools/models.py +55 -0
  42. synapse_sdk/devtools/server.py +829 -0
  43. synapse_sdk/devtools/web/.gitignore +2 -0
  44. synapse_sdk/devtools/web/README.md +34 -0
  45. synapse_sdk/devtools/web/dist/index.html +17 -0
  46. synapse_sdk/devtools/web/index.html +16 -0
  47. synapse_sdk/devtools/web/jsconfig.json +15 -0
  48. synapse_sdk/devtools/web/package-lock.json +2609 -0
  49. synapse_sdk/devtools/web/package.json +27 -0
  50. synapse_sdk/devtools/web/pnpm-lock.yaml +1055 -0
  51. synapse_sdk/devtools/web/src/App.jsx +14 -0
  52. synapse_sdk/devtools/web/src/App.module.css +33 -0
  53. synapse_sdk/devtools/web/src/assets/favicon.ico +0 -0
  54. synapse_sdk/devtools/web/src/components/Breadcrumbs.jsx +42 -0
  55. synapse_sdk/devtools/web/src/components/Layout.jsx +12 -0
  56. synapse_sdk/devtools/web/src/components/LogViewer.jsx +266 -0
  57. synapse_sdk/devtools/web/src/components/MessageViewer.jsx +150 -0
  58. synapse_sdk/devtools/web/src/components/NavigationSidebar.jsx +137 -0
  59. synapse_sdk/devtools/web/src/components/ServerStatusBar.jsx +245 -0
  60. synapse_sdk/devtools/web/src/components/icons.jsx +325 -0
  61. synapse_sdk/devtools/web/src/index.css +470 -0
  62. synapse_sdk/devtools/web/src/index.jsx +15 -0
  63. synapse_sdk/devtools/web/src/logo.svg +1 -0
  64. synapse_sdk/devtools/web/src/router.jsx +34 -0
  65. synapse_sdk/devtools/web/src/utils/api.js +425 -0
  66. synapse_sdk/devtools/web/src/views/ApplicationDetailView.jsx +241 -0
  67. synapse_sdk/devtools/web/src/views/ApplicationsView.jsx +224 -0
  68. synapse_sdk/devtools/web/src/views/HomeView.jsx +197 -0
  69. synapse_sdk/devtools/web/src/views/JobDetailView.jsx +310 -0
  70. synapse_sdk/devtools/web/src/views/PluginView.jsx +914 -0
  71. synapse_sdk/devtools/web/vite.config.js +13 -0
  72. synapse_sdk/plugins/categories/neural_net/actions/tune.py +1 -1
  73. synapse_sdk/plugins/categories/neural_net/templates/config.yaml +15 -3
  74. synapse_sdk/plugins/categories/neural_net/templates/plugin/inference.py +26 -10
  75. synapse_sdk/plugins/categories/pre_annotation/actions/to_task.py +236 -64
  76. synapse_sdk/plugins/categories/pre_annotation/templates/config.yaml +14 -2
  77. synapse_sdk/plugins/templates/plugin-config-schema.json +409 -0
  78. synapse_sdk/plugins/templates/schema.json +484 -0
  79. synapse_sdk/utils/converters/__init__.py +145 -0
  80. synapse_sdk/utils/converters/coco/__init__.py +0 -0
  81. synapse_sdk/utils/converters/coco/from_dm.py +269 -0
  82. {synapse_sdk-1.0.0a59.dist-info → synapse_sdk-1.0.0a61.dist-info}/METADATA +9 -22
  83. {synapse_sdk-1.0.0a59.dist-info → synapse_sdk-1.0.0a61.dist-info}/RECORD +87 -19
  84. {synapse_sdk-1.0.0a59.dist-info → synapse_sdk-1.0.0a61.dist-info}/WHEEL +0 -0
  85. {synapse_sdk-1.0.0a59.dist-info → synapse_sdk-1.0.0a61.dist-info}/entry_points.txt +0 -0
  86. {synapse_sdk-1.0.0a59.dist-info → synapse_sdk-1.0.0a61.dist-info}/licenses/LICENSE +0 -0
  87. {synapse_sdk-1.0.0a59.dist-info → synapse_sdk-1.0.0a61.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,829 @@
1
+ import json
2
+ import logging
3
+ import webbrowser
4
+ from contextlib import asynccontextmanager
5
+ from datetime import datetime
6
+ from importlib.metadata import version
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional
9
+
10
+ import requests
11
+ import uvicorn
12
+ import yaml
13
+ from fastapi import FastAPI, HTTPException, Request, WebSocket
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import StreamingResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+
18
+ from synapse_sdk.clients.agent import AgentClient
19
+ from synapse_sdk.clients.backend import BackendClient
20
+ from synapse_sdk.clients.exceptions import ClientError
21
+ from synapse_sdk.devtools.config import get_backend_config, load_devtools_config
22
+ from synapse_sdk.devtools.models import ConfigResponse, ConfigUpdateRequest
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class DevtoolsServer:
28
+ def __init__(self, host: str = '0.0.0.0', port: int = 8080, plugin_directory: str = None):
29
+ self.host = host
30
+ self.port = port
31
+
32
+ self.agent_id = None
33
+ self.plugin_code = None
34
+ self.plugin_directory = Path(plugin_directory) if plugin_directory else Path.cwd()
35
+ self.websocket_connections: List[WebSocket] = []
36
+ self.app = FastAPI(title='Synapse Devtools', version='1.0.0', lifespan=self.lifespan)
37
+ self._cached_validation_result = None # Cache validation result from startup
38
+
39
+ self.web_build_dir = Path(__file__).parent / 'web' / 'dist'
40
+ self.web_public_dir = Path(__file__).parent / 'web' / 'public'
41
+
42
+ self.setup_middleware()
43
+ self.setup_static_files()
44
+ self.setup_routes()
45
+
46
+ # Initialize clients if config is available
47
+ self.backend_client = self._init_backend_client()
48
+ self.agent_client = self._init_agent_client()
49
+
50
+ @asynccontextmanager
51
+ async def lifespan(self, app: FastAPI):
52
+ # Startup
53
+ logger.info('DevTools server starting up...')
54
+ yield
55
+ # Shutdown
56
+ logger.info('DevTools server shutting down...')
57
+ # Close all WebSocket connections
58
+ if self.websocket_connections:
59
+ logger.info(f'Closing {len(self.websocket_connections)} WebSocket connections')
60
+ for connection in self.websocket_connections[:]:
61
+ try:
62
+ await connection.close(code=1000, reason='Server shutdown')
63
+ except Exception as e:
64
+ logger.error(f'Error closing WebSocket connection: {e}')
65
+ self.websocket_connections.clear()
66
+ logger.info('DevTools server shutdown complete')
67
+
68
+ def _init_backend_client(self) -> Optional[BackendClient]:
69
+ config = get_backend_config()
70
+ if config:
71
+ return BackendClient(config['host'], api_token=config['token'])
72
+ return None
73
+
74
+ def _init_agent_client(self) -> Optional[AgentClient]:
75
+ devtools_config = load_devtools_config()
76
+ agent_config = devtools_config.get('agent', {})
77
+ if agent_config and 'url' in agent_config:
78
+ self.agent_id = agent_config['id']
79
+ # Use shorter timeouts for better UX in devtools
80
+ timeout = {
81
+ 'connect': 3, # 3 second connection timeout
82
+ 'read': 10, # 10 second read timeout
83
+ }
84
+ return AgentClient(agent_config['url'], agent_config.get('token'), timeout=timeout)
85
+ return None
86
+
87
+ def _validate_plugin_config(self) -> Dict:
88
+ """Validate plugin config.yaml in the plugin directory"""
89
+
90
+ return {'valid': True, 'errors': None, 'schema_version': 'unknown', 'validated_fields': 0}
91
+
92
+ async def _publish_plugin_internal(self, host: str, access_token: str, debug: bool, source_path: Path) -> Dict:
93
+ """Internal method to publish plugin using CLI logic"""
94
+ import os
95
+
96
+ from synapse_sdk.clients.backend import BackendClient
97
+ from synapse_sdk.plugins.models import PluginRelease
98
+ from synapse_sdk.plugins.upload import archive
99
+
100
+ # Change to plugin directory
101
+ original_cwd = os.getcwd()
102
+ try:
103
+ os.chdir(str(source_path))
104
+
105
+ # Create plugin release from config in plugin directory
106
+ plugin_release = PluginRelease()
107
+
108
+ # Create archive
109
+ archive_path = source_path / 'dist' / 'archive.zip'
110
+ archive(source_path, archive_path)
111
+
112
+ # Prepare data for backend
113
+ data = {'plugin': plugin_release.plugin, 'file': str(archive_path), 'debug': debug}
114
+
115
+ if debug:
116
+ modules = [] # TODO: Get debug modules from environment or config
117
+ data['meta'] = {'modules': modules}
118
+
119
+ # Create backend client and publish
120
+ client = BackendClient(host, access_token, debug=debug)
121
+ result = client.create_plugin_release(data)
122
+
123
+ return {
124
+ 'success': True,
125
+ 'message': f'Successfully published "{plugin_release.name}" '
126
+ f'({plugin_release.code}) to synapse backend!',
127
+ 'plugin_code': plugin_release.code,
128
+ 'version': plugin_release.version,
129
+ 'name': plugin_release.name,
130
+ 'result': result,
131
+ }
132
+
133
+ finally:
134
+ os.chdir(original_cwd)
135
+
136
+ async def _execute_plugin_http_request(
137
+ self,
138
+ action: str,
139
+ params: Dict,
140
+ debug: bool,
141
+ access_token: str,
142
+ ) -> Dict:
143
+ """Execute an HTTP request to the plugin endpoint"""
144
+ import asyncio
145
+ import time
146
+
147
+ import aiohttp
148
+
149
+ plugin_url = f'{self.backend_client.base_url}/plugins/{self.plugin_code}/run/'
150
+
151
+ headers = {
152
+ 'Accept': 'application/json; indent=4',
153
+ 'Content-Type': 'application/json',
154
+ 'Synapse-Access-Token': f'Token {access_token}',
155
+ }
156
+
157
+ payload = {'agent': self.agent_id, 'action': action, 'params': params, 'debug': debug}
158
+
159
+ start_time = time.time()
160
+
161
+ try:
162
+ timeout = aiohttp.ClientTimeout(total=30) # 30 second timeout
163
+ async with aiohttp.ClientSession(timeout=timeout) as session:
164
+ async with session.post(plugin_url, json=payload, headers=headers) as response:
165
+ execution_time = int((time.time() - start_time) * 1000)
166
+
167
+ response_text = await response.text()
168
+
169
+ # Try to parse as JSON, fallback to text
170
+ try:
171
+ response_data = await response.json()
172
+ except Exception:
173
+ response_data = response_text
174
+
175
+ return {
176
+ 'success': response.status < 400,
177
+ 'status_code': response.status,
178
+ 'response_data': response_data,
179
+ 'execution_time': execution_time,
180
+ 'url': plugin_url,
181
+ 'method': 'POST',
182
+ 'headers': headers,
183
+ 'payload': payload,
184
+ }
185
+
186
+ except asyncio.TimeoutError:
187
+ execution_time = int((time.time() - start_time) * 1000)
188
+ return {
189
+ 'success': False,
190
+ 'status_code': 408,
191
+ 'error': 'Request timeout',
192
+ 'execution_time': execution_time,
193
+ 'url': plugin_url,
194
+ }
195
+ except Exception as e:
196
+ execution_time = int((time.time() - start_time) * 1000)
197
+ return {
198
+ 'success': False,
199
+ 'status_code': 500,
200
+ 'error': str(e),
201
+ 'execution_time': execution_time,
202
+ 'url': plugin_url,
203
+ }
204
+
205
+ def _get_action_example_params(self, action: str) -> Dict:
206
+ """Get pre-filled example parameters for different actions based on test.http"""
207
+ examples = {
208
+ 'tune': {
209
+ 'num_cpus': 2,
210
+ 'num_gpus': 1,
211
+ 'name': 'tune-test-example',
212
+ 'description': 'Tuning example',
213
+ 'experiment': 37,
214
+ 'dataset': 55,
215
+ 'checkpoint': 163,
216
+ 'tune_config': {'metric': 'metrics/mAP50-95(B)', 'mode': 'max', 'num_samples': 5},
217
+ 'hyperparameter': [
218
+ {'name': 'learning_rate', 'type': 'loguniform', 'min': 1e-4, 'max': 1e-1},
219
+ {'name': 'batch_size', 'type': 'choice', 'options': [8, 16, 32]},
220
+ {'name': 'epochs', 'type': 'randint', 'min': 1, 'max': 2},
221
+ ],
222
+ },
223
+ 'deployment': {'num_cpus': 4, 'num_gpus': 1},
224
+ 'train': {
225
+ 'num_cpus': 2,
226
+ 'num_gpus': 1,
227
+ 'name': 'train-test-example',
228
+ 'description': 'Training example',
229
+ 'experiment': 32,
230
+ 'dataset': 55,
231
+ 'checkpoint': 8,
232
+ 'hyperparameter': {'epochs': 10, 'batch_size': 8, 'learning_rate': 0.001, 'imgsz': 640},
233
+ },
234
+ 'gradio': {'num_cpus': 4},
235
+ 'inference': {'num_cpus': 2, 'num_gpus': 1},
236
+ 'test': {'num_cpus': 1},
237
+ }
238
+
239
+ return examples.get(action, {})
240
+
241
+ def setup_middleware(self):
242
+ self.app.add_middleware(
243
+ CORSMiddleware,
244
+ allow_origins=['*'],
245
+ allow_credentials=True,
246
+ allow_methods=['*'],
247
+ allow_headers=['*'],
248
+ )
249
+
250
+ def setup_static_files(self):
251
+ # Serve built Vue.js assets from web/dist/
252
+ if self.web_build_dir.exists():
253
+ # Serve Vue.js built assets
254
+ assets_dir = self.web_build_dir / 'assets'
255
+ if assets_dir.exists():
256
+ self.app.mount('/assets', StaticFiles(directory=assets_dir), name='assets')
257
+ else:
258
+ logger.warning('Vue.js dist directory not found, devtools may not work properly')
259
+
260
+ # Serve public files (like logo.png) during development
261
+ if self.web_public_dir.exists():
262
+ self.app.mount('/public', StaticFiles(directory=self.web_public_dir), name='public')
263
+
264
+ def _inject_env_vars(self, html_content: str) -> str:
265
+ """Inject environment variables into HTML"""
266
+ env_script = f"""<script>
267
+ window.VITE_API_PORT = {self.port};
268
+ window.VITE_API_HOST = '{self.host}';
269
+ </script>"""
270
+
271
+ # Insert before closing head tag
272
+ return html_content.replace('</head>', f'{env_script}</head>')
273
+
274
+ def setup_routes(self):
275
+ @self.app.get('/')
276
+ async def read_root():
277
+ index_file = self.web_build_dir / 'index.html'
278
+
279
+ if index_file.exists():
280
+ # Read and modify HTML content to inject environment variables
281
+ with open(index_file, 'r') as f:
282
+ html_content = f.read()
283
+
284
+ modified_html = self._inject_env_vars(html_content)
285
+
286
+ from fastapi.responses import HTMLResponse
287
+
288
+ return HTMLResponse(content=modified_html)
289
+ return {'message': 'Synapse Devtools API', 'docs': '/docs'}
290
+
291
+ @self.app.get('/status')
292
+ async def get_status():
293
+ from synapse_sdk.devtools.config import load_devtools_config
294
+
295
+ backend_config = get_backend_config()
296
+ devtools_config = load_devtools_config()
297
+ agent_config = devtools_config.get('agent', {})
298
+
299
+ status = {
300
+ 'backend': {
301
+ 'host': self.backend_client.base_url,
302
+ 'status': 'configured' if backend_config else 'not_configured',
303
+ },
304
+ 'agent': {
305
+ 'id': agent_config.get('id'),
306
+ 'name': agent_config.get('name'),
307
+ },
308
+ 'devtools': {'version': version('synapse-sdk')},
309
+ }
310
+
311
+ return status
312
+
313
+ @self.app.get('/auth/token')
314
+ async def get_auth_token():
315
+ """Get the current authentication token from configuration"""
316
+ backend_config = get_backend_config()
317
+
318
+ if backend_config and 'token' in backend_config:
319
+ return {'token': backend_config['token'], 'host': backend_config['host']}
320
+
321
+ return {'token': None, 'host': None}
322
+
323
+ @self.app.get('/jobs')
324
+ async def get_jobs():
325
+ """Get the list of jobs from the backend"""
326
+ if not self.agent_client:
327
+ raise HTTPException(status_code=503, detail='Agent client not configured')
328
+
329
+ try:
330
+ jobs = self.agent_client.list_jobs()
331
+ jobs.sort(key=lambda job: job.get('start_time', 0), reverse=True)
332
+ return jobs
333
+ except ClientError as e:
334
+ logger.warning(f'Agent client error while fetching jobs: {e.reason}')
335
+ if e.status in [408, 503]:
336
+ raise HTTPException(status_code=503, detail=f'Agent unavailable: {e.reason}')
337
+ else:
338
+ raise HTTPException(status_code=e.status, detail=e.reason)
339
+ except Exception as e:
340
+ logger.error(f'Unexpected error fetching jobs: {e}')
341
+ raise HTTPException(status_code=500, detail='Failed to fetch jobs')
342
+
343
+ @self.app.get('/jobs/{job_id}')
344
+ async def get_job(job_id: str):
345
+ """Get details of a specific job by ID"""
346
+ if not self.agent_client:
347
+ raise HTTPException(status_code=503, detail='Agent client not configured')
348
+
349
+ try:
350
+ job = self.agent_client.get_job(job_id)
351
+ return job
352
+ except Exception as e:
353
+ logger.error(f'Failed to fetch job {job_id}: {e}')
354
+ raise HTTPException(status_code=500, detail=f'Failed to fetch job {job_id}')
355
+
356
+ @self.app.get('/jobs/{job_id}/logs')
357
+ async def get_job_logs(job_id: str, request: Request):
358
+ """Get logs for a specific job by ID"""
359
+ if not self.agent_client:
360
+ raise HTTPException(status_code=503, detail='Agent client not configured')
361
+
362
+ async def log_generator():
363
+ try:
364
+ # Use shorter timeout for log streaming to prevent hanging
365
+ for log_line in self.agent_client.tail_job_logs(job_id, stream_timeout=5):
366
+ # Check if client disconnected
367
+ if await request.is_disconnected():
368
+ logger.info(f'Client disconnected, stopping log stream for job {job_id}')
369
+ break
370
+ yield log_line
371
+ except ClientError as e:
372
+ logger.warning(f'Agent client error in log streaming for job {job_id}: {e.reason}')
373
+ if e.status == 408:
374
+ yield 'data: Log stream timeout - agent may be unresponsive\n\n'
375
+ elif e.status == 503:
376
+ yield 'data: Agent connection error - service may be unavailable\n\n'
377
+ else:
378
+ yield f'data: Agent error: {e.reason}\n\n'
379
+ except Exception as e:
380
+ logger.error(f'Unexpected error in log streaming for job {job_id}: {e}')
381
+ yield f'data: Unexpected error: {str(e)}\n\n'
382
+ finally:
383
+ logger.info(f'Log streaming ended for job {job_id}')
384
+
385
+ try:
386
+ return StreamingResponse(log_generator(), media_type='text/plain')
387
+ except Exception as e:
388
+ logger.error(f'Failed to setup log stream for job {job_id}: {e}')
389
+ raise HTTPException(status_code=500, detail=f'Failed to setup log stream for job {job_id}')
390
+
391
+ @self.app.get('/serve_applications')
392
+ async def get_serve_applications(request: Request):
393
+ """Get the list of serve applications from the agent or serve SPA"""
394
+ # Check if this is an API request (AJAX call) vs browser navigation
395
+ accept_header = request.headers.get('accept', '')
396
+
397
+ # If browser navigation (HTML request), serve the SPA
398
+ if 'text/html' in accept_header and 'application/json' not in accept_header:
399
+ index_file = self.web_build_dir / 'index.html'
400
+ if index_file.exists():
401
+ # Read and modify HTML content to inject environment variables
402
+ with open(index_file, 'r') as f:
403
+ html_content = f.read()
404
+
405
+ modified_html = self._inject_env_vars(html_content)
406
+
407
+ from fastapi.responses import HTMLResponse
408
+
409
+ return HTMLResponse(content=modified_html)
410
+ return {'message': 'Frontend not built'}
411
+
412
+ # Otherwise, handle as API request
413
+ if not self.agent_client:
414
+ raise HTTPException(status_code=503, detail='Agent client not configured')
415
+
416
+ try:
417
+ applications = self.agent_client.list_serve_applications()
418
+ # Sort by creation time if available, otherwise alphabetically by name
419
+ if applications:
420
+ applications.sort(key=lambda app: app.get('created_at', app.get('name', '')), reverse=True)
421
+ return applications
422
+ except ClientError as e:
423
+ logger.warning(f'Agent client error while fetching applications: {e.reason}')
424
+ if e.status in [408, 503]:
425
+ raise HTTPException(status_code=503, detail=f'Agent unavailable: {e.reason}')
426
+ else:
427
+ raise HTTPException(status_code=e.status, detail=e.reason)
428
+ except Exception as e:
429
+ logger.error(f'Unexpected error fetching applications: {e}')
430
+ raise HTTPException(status_code=500, detail='Failed to fetch applications')
431
+
432
+ @self.app.get('/serve_applications/{app_id}')
433
+ async def get_serve_application(app_id: str):
434
+ """Get details of a specific application by ID"""
435
+ if not self.agent_client:
436
+ raise HTTPException(status_code=503, detail='Agent client not configured')
437
+
438
+ try:
439
+ application = self.agent_client.get_serve_application(app_id)
440
+ return application
441
+ except Exception as e:
442
+ logger.error(f'Failed to fetch application {app_id}: {e}')
443
+ raise HTTPException(status_code=500, detail=f'Failed to fetch application {app_id}')
444
+
445
+ @self.app.delete('/serve_applications/{app_id}')
446
+ async def delete_serve_application(app_id: str):
447
+ """Delete a serve application by ID"""
448
+ if not self.agent_client:
449
+ raise HTTPException(status_code=503, detail='Agent client not configured')
450
+
451
+ try:
452
+ result = self.agent_client.delete_serve_application(app_id)
453
+ return {'message': f'Application {app_id} deleted successfully', 'result': result}
454
+ except Exception as e:
455
+ logger.error(f'Failed to delete application {app_id}: {e}')
456
+ raise HTTPException(status_code=500, detail=f'Failed to delete application {app_id}')
457
+
458
+ @self.app.get('/health/backend')
459
+ async def check_backend_health():
460
+ """Check backend health status"""
461
+ if not self.backend_client:
462
+ return {'status': 'not_configured', 'error': 'Backend client not configured', 'lastCheck': None}
463
+
464
+ import time
465
+
466
+ start_time = time.time()
467
+
468
+ try:
469
+ # Use the backend client to check health with shorter timeout
470
+ response = requests.get(f'{self.backend_client.base_url}/health', timeout=3)
471
+ latency = int((time.time() - start_time) * 1000)
472
+
473
+ if response.status_code == 200:
474
+ return {
475
+ 'status': 'healthy',
476
+ 'latency': latency,
477
+ 'lastCheck': time.time(),
478
+ 'url': f'{self.backend_client.base_url}',
479
+ 'httpStatus': response.status_code,
480
+ }
481
+ else:
482
+ status_map = {
483
+ 401: 'auth_error',
484
+ 403: 'forbidden',
485
+ 404: 'not_found',
486
+ 500: 'down',
487
+ 502: 'down',
488
+ 503: 'down',
489
+ 504: 'down',
490
+ }
491
+
492
+ return {
493
+ 'status': status_map.get(response.status_code, 'down'),
494
+ 'lastCheck': time.time(),
495
+ 'url': f'{self.backend_client.host}/health',
496
+ 'httpStatus': response.status_code,
497
+ 'error': f'HTTP {response.status_code}',
498
+ }
499
+
500
+ except requests.exceptions.Timeout:
501
+ return {
502
+ 'status': 'timeout',
503
+ 'lastCheck': time.time(),
504
+ 'url': f'{self.backend_client.base_url}/health',
505
+ 'error': 'Request timeout (>3s)',
506
+ }
507
+ except requests.exceptions.RequestException as e:
508
+ return {
509
+ 'status': 'down',
510
+ 'lastCheck': time.time(),
511
+ 'url': f'{self.backend_client.base_url}/health',
512
+ 'error': str(e),
513
+ }
514
+
515
+ @self.app.get('/health/agent')
516
+ async def check_agent_health():
517
+ """Check agent health status"""
518
+ if not self.agent_client:
519
+ return {'status': 'not_configured', 'error': 'Agent client not configured', 'lastCheck': None}
520
+
521
+ import time
522
+
523
+ start_time = time.time()
524
+
525
+ try:
526
+ # Use the agent client to check health with shorter timeout
527
+ response = requests.get(
528
+ f'{self.agent_client.base_url}/health',
529
+ timeout=3,
530
+ headers={'Authorization': f'Token {self.agent_client.agent_token}'},
531
+ )
532
+ latency = int((time.time() - start_time) * 1000)
533
+
534
+ if response.status_code == 200:
535
+ return {
536
+ 'status': 'healthy',
537
+ 'latency': latency,
538
+ 'lastCheck': time.time(),
539
+ 'url': f'{self.agent_client.base_url}/health',
540
+ 'httpStatus': response.status_code,
541
+ }
542
+ else:
543
+ status_map = {
544
+ 401: 'auth_error',
545
+ 403: 'forbidden',
546
+ 404: 'not_found',
547
+ 500: 'down',
548
+ 502: 'down',
549
+ 503: 'down',
550
+ 504: 'down',
551
+ }
552
+
553
+ return {
554
+ 'status': status_map.get(response.status_code, 'down'),
555
+ 'lastCheck': time.time(),
556
+ 'url': f'{self.agent_client.base_url}/health',
557
+ 'httpStatus': response.status_code,
558
+ 'error': f'HTTP {response.status_code}',
559
+ }
560
+
561
+ except requests.exceptions.Timeout:
562
+ return {
563
+ 'status': 'timeout',
564
+ 'lastCheck': time.time(),
565
+ 'url': f'{self.agent_client.base_url}/health',
566
+ 'error': 'Request timeout (>3s)',
567
+ }
568
+ except requests.exceptions.RequestException as e:
569
+ return {
570
+ 'status': 'down',
571
+ 'lastCheck': time.time(),
572
+ 'url': f'{self.agent_client.base_url}/health',
573
+ 'error': str(e),
574
+ }
575
+
576
+ @self.app.get('/config')
577
+ async def get_config():
578
+ """Get the current config.yaml content with validation status"""
579
+ config_path = self.plugin_directory / 'config.yaml'
580
+
581
+ if not config_path.exists():
582
+ raise HTTPException(
583
+ status_code=404,
584
+ detail='config.yaml not found in plugin directory: '
585
+ '{self.plugin_directory}. DevTools server expects to run from within a plugin directory.',
586
+ )
587
+
588
+ try:
589
+ with open(config_path, 'r', encoding='utf-8') as f:
590
+ config_data = yaml.safe_load(f)
591
+
592
+ stat = config_path.stat()
593
+ last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
594
+
595
+ validation_result = self._validate_plugin_config()
596
+
597
+ response = ConfigResponse(config=config_data, file_path=str(config_path), last_modified=last_modified)
598
+ response_dict = response.model_dump()
599
+ response_dict['validation'] = validation_result
600
+
601
+ # variables
602
+ self.plugin_code = response_dict['config'].get('code')
603
+
604
+ return response_dict
605
+ except yaml.YAMLError as e:
606
+ raise HTTPException(status_code=422, detail=f'Invalid YAML format: {str(e)}')
607
+ except Exception as e:
608
+ logger.error(f'Failed to read config.yaml: {e}')
609
+ raise HTTPException(status_code=500, detail='Failed to read configuration file')
610
+
611
+ @self.app.put('/config')
612
+ async def update_config(request: ConfigUpdateRequest):
613
+ """Update the config.yaml file"""
614
+ config_path = self.plugin_directory / 'config.yaml'
615
+
616
+ try:
617
+ # Create backup of existing config
618
+ if config_path.exists():
619
+ backup_path = config_path.with_suffix('.yaml.bak')
620
+ config_path.rename(backup_path)
621
+
622
+ # Write new config
623
+ with open(config_path, 'w', encoding='utf-8') as f:
624
+ yaml.dump(request.config, f, default_flow_style=False, allow_unicode=True, indent=2)
625
+
626
+ # Return updated config
627
+ stat = config_path.stat()
628
+ last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
629
+
630
+ return ConfigResponse(config=request.config, file_path=str(config_path), last_modified=last_modified)
631
+ except Exception as e:
632
+ # Restore backup if write failed
633
+ backup_path = config_path.with_suffix('.yaml.bak')
634
+ if backup_path.exists():
635
+ backup_path.rename(config_path)
636
+
637
+ logger.error(f'Failed to update config.yaml: {e}')
638
+ raise HTTPException(status_code=500, detail='Failed to update configuration file')
639
+
640
+ @self.app.post('/config/validate')
641
+ async def validate_config(request: ConfigUpdateRequest):
642
+ """Validate config.yaml structure using JSON schema"""
643
+ return self._validate_plugin_config()
644
+
645
+ @self.app.post('/plugin/http')
646
+ async def execute_plugin_http(request: Request):
647
+ """Execute a plugin HTTP request with specified parameters"""
648
+ try:
649
+ body = await request.json()
650
+
651
+ # Extract request parameters
652
+ action = body.get('action')
653
+ params = body.get('params', {})
654
+ debug = body.get('debug', True)
655
+ access_token = body.get('access_token', None)
656
+
657
+ # Validate plugin config before executing
658
+ validation_result = self._validate_plugin_config()
659
+ if not validation_result['valid']:
660
+ return {
661
+ 'success': False,
662
+ 'error': 'Plugin configuration is invalid',
663
+ 'validation_errors': validation_result['errors'],
664
+ 'message': 'Please fix config.yaml before executing HTTP requests',
665
+ }
666
+
667
+ # Execute the HTTP request
668
+ result = await self._execute_plugin_http_request(
669
+ action=action, params=params, debug=debug, access_token=access_token
670
+ )
671
+
672
+ return result
673
+
674
+ except HTTPException:
675
+ raise
676
+ except Exception as e:
677
+ logger.error(f'Failed to execute plugin HTTP request: {e}')
678
+ raise HTTPException(status_code=500, detail=f'Failed to execute HTTP request: {str(e)}')
679
+
680
+ @self.app.get('/plugin/http/params/{action}')
681
+ async def get_action_params(action: str):
682
+ """Get pre-filled example parameters for a specific action"""
683
+ example_params = self._get_action_example_params(action)
684
+
685
+ return {'action': action, 'example_params': example_params, 'has_example': len(example_params) > 0}
686
+
687
+ @self.app.post('/plugin/publish')
688
+ async def publish_plugin(request: Request):
689
+ """Publish a plugin to the Synapse platform using the same logic as CLI publish"""
690
+ try:
691
+ body = await request.json()
692
+
693
+ # Extract publish configuration
694
+ host = body.get('host')
695
+ access_token = body.get('access_token') # This is the synapse access token
696
+ debug = body.get('debug', True)
697
+
698
+ # Validate required fields
699
+ if not all([host, access_token]):
700
+ raise HTTPException(status_code=400, detail='Missing required fields: host or access_token')
701
+
702
+ # Validate plugin config before publishing
703
+ validation_result = self._validate_plugin_config()
704
+ if not validation_result['valid']:
705
+ return {
706
+ 'success': False,
707
+ 'error': 'Plugin configuration is invalid',
708
+ 'validation_errors': validation_result['errors'],
709
+ 'message': 'Please fix config.yaml before publishing',
710
+ }
711
+
712
+ # Use the existing CLI publish logic
713
+ result = await self._publish_plugin_internal(
714
+ host=host, access_token=access_token, debug=debug, source_path=self.plugin_directory
715
+ )
716
+
717
+ return result
718
+
719
+ except HTTPException:
720
+ raise
721
+ except Exception as e:
722
+ logger.error(f'Failed to publish plugin: {e}')
723
+ raise HTTPException(status_code=500, detail=f'Failed to publish plugin: {str(e)}')
724
+
725
+ @self.app.get('/{path:path}')
726
+ async def spa_handler(path: str):
727
+ """Serve index.html for all non-API routes (SPA routing)"""
728
+ # Don't handle API routes, static assets, or docs
729
+ if (
730
+ path.startswith('api/')
731
+ or path.startswith('docs')
732
+ or path.startswith('redoc')
733
+ or path.startswith('assets/')
734
+ or path.startswith('public/')
735
+ or path.startswith('jobs')
736
+ or path.startswith('health/')
737
+ or path.startswith('config')
738
+ or path.startswith('status')
739
+ or path.startswith('auth/')
740
+ or path.startswith('plugin/')
741
+ or path.endswith('.js')
742
+ or path.endswith('.css')
743
+ or path.endswith('.ico')
744
+ or path.endswith('.png')
745
+ or path.endswith('.svg')
746
+ or path.endswith('.jpg')
747
+ or path.endswith('.jpeg')
748
+ or path.endswith('.woff')
749
+ or path.endswith('.woff2')
750
+ or path.endswith('.ttf')
751
+ ):
752
+ raise HTTPException(status_code=404, detail='Not found')
753
+
754
+ index_file = self.web_build_dir / 'index.html'
755
+
756
+ if index_file.exists():
757
+ # Read and modify HTML content to inject environment variables
758
+ with open(index_file, 'r') as f:
759
+ html_content = f.read()
760
+
761
+ modified_html = self._inject_env_vars(html_content)
762
+
763
+ from fastapi.responses import HTMLResponse
764
+
765
+ return HTMLResponse(content=modified_html)
766
+
767
+ # Fallback if no built frontend
768
+ return {'message': 'Synapse Devtools API', 'docs': '/docs', 'path': path}
769
+
770
+ async def broadcast_update(self, data: Dict):
771
+ """Broadcast updates to all connected WebSocket clients"""
772
+ if self.websocket_connections:
773
+ message = json.dumps(data)
774
+ disconnected_connections = []
775
+
776
+ for connection in self.websocket_connections[:]:
777
+ try:
778
+ await connection.send_text(message)
779
+ except Exception as e:
780
+ logger.error(f'Error sending WebSocket message: {e}')
781
+ disconnected_connections.append(connection)
782
+
783
+ # Remove disconnected connections
784
+ for connection in disconnected_connections:
785
+ if connection in self.websocket_connections:
786
+ self.websocket_connections.remove(connection)
787
+ logger.info('Removed disconnected WebSocket connection')
788
+
789
+ def start_server(self, open_browser: bool = True):
790
+ """Start the devtools server"""
791
+ url = f'http://{self.host}:{self.port}'
792
+
793
+ if open_browser:
794
+ # Open browser after a short delay to ensure server is ready
795
+ def open_browser_delayed():
796
+ import os
797
+ import time
798
+
799
+ time.sleep(1.5)
800
+
801
+ try:
802
+ # Redirect stderr to suppress xdg-open error messages
803
+ old_stderr = os.dup(2)
804
+ with open(os.devnull, 'w') as devnull:
805
+ os.dup2(devnull.fileno(), 2)
806
+ webbrowser.open(url)
807
+ os.dup2(old_stderr, 2)
808
+ os.close(old_stderr)
809
+ except Exception:
810
+ # Restore stderr if something went wrong
811
+ try:
812
+ os.dup2(old_stderr, 2)
813
+ os.close(old_stderr)
814
+ except Exception as e:
815
+ logger.error(f'Failed to restore stderr: {e}')
816
+
817
+ # Always show the URL since we can't reliably detect browser success
818
+ print(f'Open devtools on your browser: {url}')
819
+
820
+ import threading
821
+
822
+ threading.Thread(target=open_browser_delayed, daemon=True).start()
823
+
824
+ uvicorn.run(self.app, host=self.host, port=self.port, log_level='warning', access_log=False)
825
+
826
+
827
+ def create_devtools_server(host: str = '0.0.0.0', port: int = 8080, plugin_directory: str = None) -> DevtoolsServer:
828
+ """Factory function to create a devtools server instance"""
829
+ return DevtoolsServer(host=host, port=port, plugin_directory=plugin_directory)