Files

314 lines
10 KiB
Python

"""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/<task_id>")
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/<task_id>/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/<task_id>/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/<name>")
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