314 lines
10 KiB
Python
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
|