"""JSON API blueprint: /api/* endpoints.""" import json import logging import re import subprocess import time from typing import Any from flask import Blueprint, jsonify, request from werkzeug.exceptions import HTTPException import config from security import validate_csrf from services import gitea, kanban, projects, worker logger = logging.getLogger(__name__) bp = Blueprint("api", __name__, url_prefix="/api") @bp.route("/workers") def api_workers() -> tuple[Any, int]: """Worker status: current task, processes, soul excerpt.""" try: data = worker.get_worker_status() return jsonify(data), 200 except Exception as exc: logger.exception("api_workers failed") return jsonify({"error": str(exc)}), 200 @bp.route("/tasks") def api_tasks() -> tuple[Any, int]: """Tasks list with optional status filter.""" try: status = request.args.get("status") data = kanban.get_tasks(status) return jsonify(data), 200 except Exception as exc: logger.exception("api_tasks failed") return jsonify({"error": str(exc), "tasks": [], "counts": {}}), 200 @bp.route("/tasks/recent") def api_tasks_recent() -> tuple[Any, int]: """Recent tasks list (last 5).""" try: tasks = kanban.get_recent_tasks(5) return jsonify({"tasks": tasks}), 200 except Exception as exc: logger.exception("api_tasks_recent failed") return jsonify({"error": str(exc), "tasks": []}), 200 @bp.route("/tasks/") def api_task_detail(task_id: str) -> tuple[Any, int]: """Full task detail with events, comments, runs, attachments.""" try: data = kanban.get_task_detail(task_id) return jsonify(data), 200 except Exception as exc: logger.exception("api_task_detail failed") return jsonify({"error": str(exc)}), 200 @bp.route("/tasks//cancel", methods=["POST"]) def api_task_cancel(task_id: str) -> tuple[Any, int]: """Cancel a running task by blocking it.""" # NOTE: LAN-only dashboard, no auth. Add basic auth before exposing publicly. try: validate_csrf() db = kanban.get_db() row = db.execute("SELECT status FROM tasks WHERE id = ?", (task_id,)).fetchone() if not row: return jsonify({"error": "task not found"}), 404 status = row["status"] if status != "running": return jsonify({"error": f"task is {status}, only running tasks can be cancelled"}), 409 try: subprocess.run( ["hermes", "kanban", "block", task_id, "user requested cancel from dashboard", "--kind", "transient"], check=True, capture_output=True, text=True, timeout=15, ) except subprocess.CalledProcessError as exc: return jsonify({"error": "hermes kanban block failed", "stderr": exc.stderr[:500]}), 500 except subprocess.TimeoutExpired: return jsonify({"error": "hermes timed out"}), 500 return jsonify({"ok": True, "task_id": task_id, "status": "blocked"}), 200 except HTTPException: raise except Exception as exc: logger.exception("api_task_cancel failed") return jsonify({"error": str(exc)}), 500 @bp.route("/tasks//comment", methods=["POST"]) def api_task_comment(task_id: str) -> tuple[Any, int]: """Add a comment to a task.""" # NOTE: LAN-only dashboard, no auth. Add basic auth before exposing publicly. try: validate_csrf() body = request.get_json(silent=True) or {} text = (body.get("text") or "").strip() if not text: return jsonify({"error": "text is required and must be non-empty"}), 400 db = kanban.get_db() row = db.execute("SELECT id FROM tasks WHERE id = ?", (task_id,)).fetchone() if not row: return jsonify({"error": "task not found"}), 404 try: subprocess.run( ["hermes", "kanban", "comment", task_id, text, "--author", "dashboard"], check=True, capture_output=True, text=True, timeout=15, ) except subprocess.CalledProcessError as exc: return jsonify({"error": "hermes kanban comment failed", "stderr": exc.stderr[:500]}), 500 except subprocess.TimeoutExpired: return jsonify({"error": "hermes timed out"}), 500 return jsonify({"ok": True, "task_id": task_id}), 200 except HTTPException: raise except Exception as exc: logger.exception("api_task_comment failed") return jsonify({"error": str(exc)}), 500 @bp.route("/projects") def api_projects() -> tuple[Any, int]: """All managed projects with git info.""" try: data = projects.get_all_projects() return jsonify({"projects": data}), 200 except Exception as exc: logger.exception("api_projects failed") return jsonify({"error": str(exc), "projects": []}), 200 @bp.route("/projects/") def api_project_detail(name: str) -> tuple[Any, int]: """Detailed project info with recent commits and diff stat.""" try: data = projects.get_project_detail(name) return jsonify(data), 200 except Exception as exc: logger.exception("api_project_detail failed") return jsonify({"error": str(exc)}), 200 @bp.route("/processes") def api_processes() -> tuple[Any, int]: """Running opencode worker processes.""" try: processes = worker.get_running_processes() return jsonify({"processes": processes}), 200 except Exception as exc: logger.exception("api_processes failed") return jsonify({"error": str(exc), "processes": []}), 200 @bp.route("/soul") def api_soul() -> tuple[Any, int]: """First 2000 chars of SOUL.md.""" try: data = worker.get_soul_full() return jsonify(data), 200 except Exception as exc: logger.exception("api_soul failed") return jsonify({"error": str(exc), "content": ""}), 200 @bp.route("/configs") def api_configs() -> tuple[Any, int]: """Raw text of opencode.jsonc and omo.jsonc.""" result: dict[str, str] = {"opencode_jsonc": "", "omo_jsonc": ""} try: with open(config.OPENCODE_CONFIG_PATH, "r", encoding="utf-8") as f: result["opencode_jsonc"] = f.read() except (FileNotFoundError, PermissionError, OSError) as exc: result["opencode_jsonc"] = f"(not available: {exc})" try: with open(config.OMO_CONFIG_PATH, "r", encoding="utf-8") as f: result["omo_jsonc"] = f.read() except (FileNotFoundError, PermissionError, OSError) as exc: result["omo_jsonc"] = f"(not available: {exc})" return jsonify(result), 200 @bp.route("/gitea/repos") def api_gitea_repos() -> tuple[Any, int]: """Gitea repos list.""" try: data = gitea.get_repos() return jsonify(data), 200 except Exception as exc: logger.exception("api_gitea_repos failed") return jsonify({"error": str(exc), "repos": [], "available": False}), 200 # NOTE: LAN-only dashboard, no auth. Add basic auth before exposing publicly. @bp.route("/dispatch/templates") def api_dispatch_templates() -> tuple[Any, int]: """Available dispatch templates for task creation.""" try: return jsonify({"templates": config.DISPATCH_TEMPLATES}), 200 except Exception as exc: logger.exception("api_dispatch_templates failed") return jsonify({"error": str(exc), "templates": []}), 200 @bp.route("/dispatch", methods=["POST"]) def api_dispatch() -> tuple[Any, int]: """Create a new kanban task via hermes CLI.""" try: validate_csrf() body = request.get_json(silent=True) or {} title = (body.get("title") or "").strip() if not title: return jsonify({"error": "title is required and must be non-empty"}), 400 task_body = body.get("body") or "" max_runtime = body.get("max_runtime", 1800) if not isinstance(max_runtime, int) or max_runtime < 60 or max_runtime > 7200: return jsonify({"error": "max_runtime must be an integer between 60 and 7200"}), 400 assignee = body.get("assignee") or "omo-pm" try: result = subprocess.run( [ "hermes", "kanban", "create", title, "--body", task_body, "--assignee", assignee, "--max-runtime", str(max_runtime), "--json", ], check=True, capture_output=True, text=True, timeout=30, ) except subprocess.CalledProcessError as exc: return jsonify({"error": "hermes kanban create failed", "stderr": exc.stderr[:500]}), 500 except subprocess.TimeoutExpired: return jsonify({"error": "hermes timed out"}), 500 stdout = result.stdout task_id = None try: data = json.loads(stdout) task_id = data.get("id") or (data.get("task") or {}).get("id") except (json.JSONDecodeError, AttributeError): pass if not task_id: match = re.search(r"t_[0-9a-f]{8}", stdout) if match: task_id = match.group(0) if not task_id: return jsonify({"error": "could not parse task id", "stdout": stdout[:500]}), 500 return jsonify({ "task_id": task_id, "status": "ready", "created_at": int(time.time()), }), 200 except HTTPException: raise except Exception as exc: logger.exception("api_dispatch failed") return jsonify({"error": str(exc)}), 500 @bp.errorhandler(403) def api_forbidden(e): return jsonify({"error": "CSRF token missing or invalid", "code": 403}), 403 @bp.errorhandler(404) def api_not_found(e): return jsonify({"error": "not found", "code": 404}), 404 @bp.errorhandler(405) def api_method_not_allowed(e): return jsonify({"error": "method not allowed", "code": 405}), 405 @bp.errorhandler(500) def api_internal_error(e): logger.exception("api internal error") return jsonify({"error": "internal server error", "code": 500}), 500