Merges workspace.html + ralph.html into a single unified project hub with: - Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict) - 9-state project badge system (running-ralph/manual, planning, approved, pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix - SSE realtime + polling fallback, version-based optimistic concurrency (If-Match) - Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume) - Propose modal (Variant B: inline Plan-with-Echo checkbox) - 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar) - Inter font self-hosted + shared tokens.css design system + DESIGN.md - src/jsonlock.py (flock helper, sidecar .lock for stable inode) - src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh) - 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28 - No emoji anywhere (enforced by test_dashboard_no_emoji.py) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
442 lines
19 KiB
Python
442 lines
19 KiB
Python
#!/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 os
|
|
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.auth import AuthHandlers # noqa: E402
|
|
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.projects import ProjectsHandlers # 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 <!--NAV--> marker.
|
|
NAV_HTML = '''<header class="header">
|
|
<a href="/echo/index.html" class="logo">
|
|
<i data-lucide="circle-dot"></i>
|
|
Echo
|
|
</a>
|
|
<nav class="nav">
|
|
<a href="/echo/index.html" class="nav-item" data-page="index">
|
|
<i data-lucide="layout-dashboard"></i>
|
|
<span>Dashboard</span>
|
|
</a>
|
|
<a href="/echo/workspace.html" class="nav-item" data-page="workspace">
|
|
<i data-lucide="code"></i>
|
|
<span>Workspace</span>
|
|
</a>
|
|
<a href="/echo/notes.html" class="nav-item" data-page="notes">
|
|
<i data-lucide="file-text"></i>
|
|
<span>KB</span>
|
|
</a>
|
|
<a href="/echo/habits.html" class="nav-item" data-page="habits">
|
|
<i data-lucide="dumbbell"></i>
|
|
<span>Habits</span>
|
|
</a>
|
|
<a href="/echo/files.html" class="nav-item" data-page="files">
|
|
<i data-lucide="folder"></i>
|
|
<span>Files</span>
|
|
</a>
|
|
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
|
<i data-lucide="sun" id="themeIcon"></i>
|
|
</button>
|
|
</nav>
|
|
</header>
|
|
<script>
|
|
(function(){
|
|
var path = window.location.pathname;
|
|
var m = path.match(/([^\\/]+?)(?:\\.html)?$/);
|
|
var page = m ? m[1] : 'index';
|
|
if (!page || page === 'echo') page = 'index';
|
|
var item = document.querySelector('.nav-item[data-page="' + page + '"]');
|
|
if (item) item.classList.add('active');
|
|
})();
|
|
</script>'''
|
|
|
|
|
|
class TaskBoardHandler(
|
|
AuthHandlers,
|
|
ProjectsHandlers,
|
|
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
|
|
import os
|
|
# Static assets — served directly from dashboard/static/. Handles the
|
|
# case where the URL is hit with the /echo/ prefix intact (e.g. direct
|
|
# localhost curl); when behind the reverse proxy that strips /echo/,
|
|
# the request falls through to SimpleHTTPRequestHandler which serves
|
|
# cwd/static/ naturally (cwd is set to KANBAN_DIR/dashboard).
|
|
if self.path.startswith('/echo/static/'):
|
|
rel = self.path[len('/echo/static/'):].split('?', 1)[0]
|
|
file_path = os.path.join(os.path.dirname(__file__), 'static', rel)
|
|
if os.path.isfile(file_path):
|
|
ext = os.path.splitext(rel)[1].lstrip('.').lower()
|
|
ctype = {
|
|
'css': 'text/css',
|
|
'woff2': 'font/woff2',
|
|
'woff': 'font/woff',
|
|
'js': 'application/javascript',
|
|
'svg': 'image/svg+xml',
|
|
'png': 'image/png',
|
|
}.get(ext, 'application/octet-stream')
|
|
with open(file_path, 'rb') as f:
|
|
data = f.read()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', ctype)
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.send_header('Cache-Control', 'public, max-age=86400')
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
else:
|
|
self.send_error(404)
|
|
return
|
|
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/<slug>/log or /api/ralph/<slug>/prd
|
|
parts = self.path.split('?', 1)[0].split('/')
|
|
# parts: ['', 'api', 'ralph', '<slug>', '<action>']
|
|
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 == '/api/projects' or self.path.startswith('/api/projects?'):
|
|
self.handle_unified_status()
|
|
elif self.path == '/api/projects/signature' or self.path.startswith('/api/projects/signature?'):
|
|
self.handle_unified_signature()
|
|
elif self.path == '/api/projects/stream' or self.path.startswith('/api/projects/stream?'):
|
|
self.handle_projects_stream()
|
|
elif self.path.startswith('/api/projects/'):
|
|
# /api/projects/<slug>/plan/(state|transcript)
|
|
parts = self.path.split('?', 1)[0].split('/')
|
|
# parts: ['', 'api', 'projects', '<slug>', 'plan', '<action>']
|
|
if len(parts) >= 6 and parts[4] == 'plan':
|
|
slug = parts[3]
|
|
action = parts[5]
|
|
if action == 'state':
|
|
self.handle_plan_state(slug)
|
|
elif action == 'transcript':
|
|
self.handle_plan_transcript(slug)
|
|
else:
|
|
self.send_error(404)
|
|
else:
|
|
self.send_error(404)
|
|
elif self.path == '/echo/login' or self.path.startswith('/echo/login?'):
|
|
# If already logged in, redirect to workspace; otherwise serve
|
|
# login.html (created in Lane B2).
|
|
if self._check_dashboard_cookie():
|
|
self.send_response(302)
|
|
self.send_header('Location', '/echo/workspace.html')
|
|
self.send_header('Content-Length', '0')
|
|
self.end_headers()
|
|
return
|
|
login_html = KANBAN_DIR / 'login.html'
|
|
if login_html.is_file():
|
|
body = login_html.read_text('utf-8').replace('<!--NAV-->', NAV_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)
|
|
else:
|
|
# Lane B2 hasn't shipped yet — return 503 with a hint.
|
|
self.send_error(503, 'login.html not yet available')
|
|
elif self.path == '/ralph.html' or self.path.startswith('/ralph.html?'):
|
|
# Legacy redirect — Ralph dashboard merged into workspace.html (Lane D1).
|
|
self.send_response(302)
|
|
self.send_header('Location', '/echo/workspace.html')
|
|
self.send_header('Content-Length', '0')
|
|
self.end_headers()
|
|
return
|
|
elif self.path.startswith('/api/'):
|
|
self.send_error(404)
|
|
else:
|
|
# Inject shared nav into served HTML pages via <!--NAV--> 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-->', 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()
|
|
|
|
# POSTs that bypass the auth middleware (login itself can't require a cookie).
|
|
UNPROTECTED_POSTS = frozenset({'/api/auth/login'})
|
|
|
|
def do_POST(self):
|
|
# ── Auth middleware ────────────────────────────────────────
|
|
# Only protect /api/* POSTs for now — older endpoints predate auth and
|
|
# we want a single, well-defined gate. Static asset POSTs (none today)
|
|
# would also fall through.
|
|
path_only = self.path.split('?', 1)[0]
|
|
if path_only.startswith('/api/') and path_only not in self.UNPROTECTED_POSTS:
|
|
if not self._check_dashboard_cookie():
|
|
body = b'{"error":"Unauthorized"}'
|
|
self.send_response(401)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Cache-Control', 'no-store')
|
|
self.end_headers()
|
|
try:
|
|
self.wfile.write(body)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
return
|
|
# CSRF: require Origin (or Referer) to be on the allowlist.
|
|
origin = self.headers.get('Origin', '') or ''
|
|
referer = self.headers.get('Referer', '') or ''
|
|
allowed = ['http://127.0.0.1:8088', 'http://localhost:8088']
|
|
dh = os.environ.get('DASHBOARD_HOST', '').strip()
|
|
if dh:
|
|
allowed.append(dh)
|
|
check = origin or referer
|
|
if check and not any(check.startswith(a) for a in allowed):
|
|
body = b'{"error":"CSRF"}'
|
|
self.send_response(403)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Cache-Control', 'no-store')
|
|
self.end_headers()
|
|
try:
|
|
self.wfile.write(body)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass
|
|
return
|
|
|
|
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/<slug>/{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)
|
|
elif self.path == '/api/auth/login':
|
|
self.handle_login()
|
|
elif self.path == '/api/auth/logout':
|
|
self.handle_logout()
|
|
elif self.path == '/api/projects/propose':
|
|
self.handle_propose()
|
|
elif self.path == '/api/projects/approve':
|
|
self.handle_approve()
|
|
elif self.path == '/api/projects/unapprove':
|
|
self.handle_unapprove()
|
|
elif self.path == '/api/projects/cancel':
|
|
self.handle_cancel()
|
|
elif self.path.startswith('/api/projects/'):
|
|
# /api/projects/<slug>/plan/(start|respond|finalize|cancel|advance)
|
|
parts = self.path.split('?', 1)[0].split('/')
|
|
# parts: ['', 'api', 'projects', '<slug>', 'plan', '<action>']
|
|
if len(parts) >= 6 and parts[4] == 'plan':
|
|
slug = parts[3]
|
|
action = parts[5]
|
|
if action == 'start':
|
|
self.handle_plan_start(slug)
|
|
elif action == 'respond':
|
|
self.handle_plan_respond(slug)
|
|
elif action == 'finalize':
|
|
self.handle_plan_finalize(slug)
|
|
elif action == 'cancel':
|
|
self.handle_plan_cancel_planning(slug)
|
|
elif action == 'advance':
|
|
self.handle_plan_advance(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()
|