#!/usr/bin/env python3 """Echo Task Board API — thin HTTP router. All endpoint logic lives in `dashboard/handlers/*.py`. This file is responsible only for URL dispatch, CORS, JSON response helpers, and server bootstrap. """ import json import sys from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path # Make dashboard/ importable for the handler submodules (constants, # habits_helpers, handlers.*). Tests rely on this as well. _DASH = Path(__file__).parent if str(_DASH) not in sys.path: sys.path.insert(0, str(_DASH)) from constants import ( # noqa: E402 re-exported for tests ALLOWED_WORKSPACES, BASE_DIR, ECHO_CORE_DIR, ECHO_LOG_FILE, ECHO_SESSIONS_FILE, ECO_SERVICES, GIT_WORKSPACE, GITEA_ORG, GITEA_TOKEN, GITEA_URL, HABITS_FILE, KANBAN_DIR, NOTES_DIR, TOOLS_DIR, VENV_PYTHON, WORKSPACE_DIR, ) from handlers.cron import CronHandlers # noqa: E402 from handlers.eco import EcoHandlers # noqa: E402 from handlers.files import FilesHandlers # noqa: E402 from handlers.git import GitHandlers # noqa: E402 from handlers.habits import HabitsHandlers # noqa: E402 from handlers.pdf import PDFHandlers # noqa: E402 from handlers.ralph import RalphHandlers # noqa: E402 from handlers.workspace import WorkspaceHandlers # noqa: E402 from handlers.youtube import YoutubeHandlers # noqa: E402 # Shared navigation injected into every served .html via marker. NAV_HTML = '''
''' class TaskBoardHandler( GitHandlers, HabitsHandlers, EcoHandlers, FilesHandlers, PDFHandlers, YoutubeHandlers, WorkspaceHandlers, RalphHandlers, CronHandlers, SimpleHTTPRequestHandler, ): """HTTP request handler — dispatches to handler-mixin methods.""" # ── shared utilities ──────────────────────────────────────── def _read_post_json(self): """Read a JSON body from the POST request.""" content_length = int(self.headers['Content-Length']) post_data = self.rfile.read(content_length).decode('utf-8') return json.loads(post_data) def send_json(self, data, code=200): self.send_response(code) self.send_header('Content-Type', 'application/json') self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') self.send_header('Pragma', 'no-cache') self.send_header('Expires', '0') self.end_headers() self.wfile.write(json.dumps(data).encode()) def do_OPTIONS(self): self.send_response(200) self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Content-Type') self.end_headers() # ── dispatch ──────────────────────────────────────────────── def do_GET(self): from datetime import datetime as _dt if self.path == '/api/status': self.send_json({'status': 'ok', 'time': _dt.now().isoformat()}) elif self.path == '/api/git' or self.path.startswith('/api/git?'): self.handle_git_status() elif self.path == '/api/cron' or self.path.startswith('/api/cron?'): self.handle_cron_status() elif self.path == '/api/habits': self.handle_habits_get() elif self.path.startswith('/api/files'): self.handle_files_get() elif self.path.startswith('/api/diff'): self.handle_git_diff() elif self.path == '/api/workspace' or self.path.startswith('/api/workspace?'): self.handle_workspace_list() elif self.path.startswith('/api/workspace/git/diff'): self.handle_workspace_git_diff() elif self.path.startswith('/api/workspace/logs'): self.handle_workspace_logs() elif self.path == '/api/eco/status' or self.path.startswith('/api/eco/status?'): self.handle_eco_status() elif self.path == '/api/eco/sessions' or self.path.startswith('/api/eco/sessions?'): self.handle_eco_sessions() elif self.path.startswith('/api/eco/sessions/content'): self.handle_eco_session_content() elif self.path.startswith('/api/eco/logs'): self.handle_eco_logs() elif self.path == '/api/eco/doctor': self.handle_eco_doctor() elif self.path == '/api/ralph/status' or self.path.startswith('/api/ralph/status?'): self.handle_ralph_status() elif self.path == '/api/ralph/usage' or self.path.startswith('/api/ralph/usage?'): self.handle_ralph_usage() elif self.path == '/api/ralph/stream' or self.path.startswith('/api/ralph/stream?'): self.handle_ralph_stream() elif self.path.startswith('/api/ralph/'): # /api/ralph//log or /api/ralph//prd parts = self.path.split('?', 1)[0].split('/') # parts: ['', 'api', 'ralph', '', ''] if len(parts) >= 5: slug = parts[3] action = parts[4] if action == 'log': self.handle_ralph_log(slug) elif action == 'prd': self.handle_ralph_prd(slug) else: self.send_error(404) else: self.send_error(404) elif self.path.startswith('/api/'): self.send_error(404) else: # Inject shared nav into served HTML pages via marker. rel = self.path.lstrip('/').split('?')[0] if rel.endswith('.html'): try: fpath = (KANBAN_DIR / rel).resolve() fpath.relative_to(KANBAN_DIR.resolve()) except (ValueError, OSError): self.send_error(403) return if fpath.is_file(): html = fpath.read_text('utf-8').replace('', NAV_HTML) body = html.encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-cache') self.end_headers() self.wfile.write(body) return super().do_GET() def do_POST(self): if self.path == '/api/youtube': self.handle_youtube() elif self.path == '/api/files': self.handle_files_post() elif self.path == '/api/refresh-index': self.handle_refresh_index() elif self.path == '/api/pdf': self.handle_pdf_post() elif self.path == '/api/habits': self.handle_habits_post() elif self.path.startswith('/api/habits/') and self.path.endswith('/check'): self.handle_habits_check() elif self.path.startswith('/api/habits/') and self.path.endswith('/skip'): self.handle_habits_skip() elif self.path == '/api/workspace/run': self.handle_workspace_run() elif self.path == '/api/workspace/stop': self.handle_workspace_stop() elif self.path == '/api/workspace/git/commit': self.handle_workspace_git_commit() elif self.path == '/api/workspace/git/push': self.handle_workspace_git_push() elif self.path == '/api/workspace/delete': self.handle_workspace_delete() elif self.path == '/api/eco/restart': self.handle_eco_restart() elif self.path == '/api/eco/stop': self.handle_eco_stop() elif self.path == '/api/eco/sessions/clear': self.handle_eco_sessions_clear() elif self.path == '/api/eco/git-commit': self.handle_eco_git_commit() elif self.path == '/api/eco/restart-taskboard': self.handle_eco_restart_taskboard() elif self.path.startswith('/api/ralph/'): # /api/ralph//{stop,rollback} parts = self.path.split('?', 1)[0].split('/') if len(parts) >= 5: slug = parts[3] action = parts[4] if action == 'stop': self.handle_ralph_stop(slug) elif action == 'rollback': self.handle_ralph_rollback(slug) else: self.send_error(404) else: self.send_error(404) else: self.send_error(404) def do_PUT(self): if self.path.startswith('/api/habits/'): self.handle_habits_put() else: self.send_error(404) def do_DELETE(self): if self.path.startswith('/api/habits/') and '/check' in self.path: self.handle_habits_uncheck() elif self.path.startswith('/api/habits/'): self.handle_habits_delete() else: self.send_error(404) if __name__ == '__main__': import os port = 8088 os.chdir(KANBAN_DIR) print(f"Starting Echo Task Board API on port {port}") # ThreadingHTTPServer permite SSE long-lived (/api/ralph/stream) fără să # blocheze celelalte request-uri. httpd = ThreadingHTTPServer(('0.0.0.0', port), TaskBoardHandler) httpd.daemon_threads = True httpd.serve_forever()