fix: skills_api aiohttp

This commit is contained in:
Felipe Domingues 2026-04-27 17:36:15 -03:00
parent 1f2fc5f5a9
commit a68af047c2
4 changed files with 350 additions and 39 deletions

View file

@ -1,25 +1,39 @@
"""
Injeta `from skills_api import router as skills_router` + `app.include_router(...)`
no api_server.py do Hermes durante o build da imagem.
Injeta o registro das rotas aiohttp do skills_api dentro do método start()
do APIServerAdapter no api_server.py do Hermes.
Idempotente: se o marcador existir, não faz nada.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
MARKER = "# >>> hermes-custom: skills_api <<<"
INJECT = f"""
{MARKER}
try:
from skills_api import router as _skills_router
app.include_router(_skills_router)
print("[hermes-custom] skills_api router registered", flush=True)
except Exception as _e:
print(f"[hermes-custom] failed to register skills_api: {{_e}}", flush=True)
# <<< hermes-custom: skills_api >>>
"""
# Regex: captura uma linha que cria a aiohttp Application, ex:
# self._app = web.Application(...)
# app = web.Application(...)
APP_ASSIGN_RE = re.compile(
r"^(?P<indent>[ \t]+)(?P<lhs>(?:self\._?app|app))\s*=\s*web\.Application\([^)]*\)\s*$",
re.MULTILINE,
)
def build_injection(indent: str, app_var: str) -> str:
lines = [
"",
f"{indent}{MARKER}",
f"{indent}try:",
f"{indent} from skills_api import skills_routes as _skills_routes",
f"{indent} {app_var}.add_routes(_skills_routes)",
f'{indent} print("[hermes-custom] skills_api routes registered", flush=True)',
f"{indent}except Exception as _e:",
f'{indent} print(f"[hermes-custom] failed to register skills_api: {{_e}}", flush=True)',
f"{indent}# <<< hermes-custom: skills_api >>>",
]
return "\n".join(lines)
def main(target: str) -> int:
@ -33,12 +47,25 @@ def main(target: str) -> int:
print("[apply_patch] already applied, skipping")
return 0
# Procura a linha onde o `app = FastAPI(...)` é instanciado e injeta logo
# após o bloco de criação. Estratégia: anexa no final do arquivo — mais
# robusto contra mudanças upstream do que tentar achar a linha exata.
new_src = src.rstrip() + "\n\n" + INJECT + "\n"
match = APP_ASSIGN_RE.search(src)
if not match:
print(
"[apply_patch] ERROR: não encontrei `... = web.Application(...)` no api_server.py",
file=sys.stderr,
)
return 2
indent = match.group("indent")
app_var = match.group("lhs")
insert_at = match.end()
injection = build_injection(indent, app_var)
new_src = src[:insert_at] + injection + src[insert_at:]
p.write_text(new_src, encoding="utf-8")
print(f"[apply_patch] injected at offset {len(src)}")
print(
f"[apply_patch] injected after `{app_var} = web.Application(...)` "
f"at offset {insert_at} (indent={len(indent)} spaces)"
)
return 0

View file

@ -1,5 +1,6 @@
"""
Skills API router injetado no api_server.py do Hermes pelo apply_patch.py.
Skills API (aiohttp) injetado no api_server.py do Hermes via apply_patch.py.
Expõe:
GET /api/skills lista skills do tenant
POST /api/skills/sync upsert em lote (chamado pelo Lovable)
@ -7,13 +8,14 @@ Expõe:
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Header, HTTPException
from aiohttp import web
router = APIRouter(prefix="/api/skills", tags=["skills"])
skills_routes = web.RouteTableDef()
SKILLS_DIR = Path(os.environ.get("HERMES_SKILLS_DIR", "/opt/data/.hermes/skills"))
SYNC_TOKEN = os.environ.get("HERMES_SKILLS_SYNC_TOKEN", "")
@ -25,36 +27,41 @@ def _ensure_dir() -> None:
def _check_auth(token: str | None) -> None:
if not SYNC_TOKEN:
raise HTTPException(500, "HERMES_SKILLS_SYNC_TOKEN não configurado")
raise web.HTTPInternalServerError(reason="HERMES_SKILLS_SYNC_TOKEN não configurado")
if token != SYNC_TOKEN:
raise HTTPException(401, "Token inválido")
raise web.HTTPUnauthorized(reason="Token inválido")
@router.get("/health")
def health() -> dict[str, Any]:
@skills_routes.get("/api/skills/health")
async def health(_request: web.Request) -> web.Response:
_ensure_dir()
return {"ok": True, "dir": str(SKILLS_DIR), "count": len(list(SKILLS_DIR.glob("*.md")))}
return web.json_response(
{"ok": True, "dir": str(SKILLS_DIR), "count": len(list(SKILLS_DIR.glob("*.md")))}
)
@router.get("")
def list_skills() -> dict[str, Any]:
@skills_routes.get("/api/skills")
async def list_skills(_request: web.Request) -> web.Response:
_ensure_dir()
items = []
items: list[dict[str, Any]] = []
for f in sorted(SKILLS_DIR.glob("*.md")):
items.append({"name": f.stem, "size": f.stat().st_size})
return {"skills": items}
return web.json_response({"skills": items})
@router.post("/sync")
def sync_skills(
payload: dict[str, Any],
x_sync_token: str | None = Header(default=None, alias="X-Sync-Token"),
) -> dict[str, Any]:
_check_auth(x_sync_token)
@skills_routes.post("/api/skills/sync")
async def sync_skills(request: web.Request) -> web.Response:
_check_auth(request.headers.get("X-Sync-Token"))
_ensure_dir()
try:
payload = await request.json()
except json.JSONDecodeError:
raise web.HTTPBadRequest(reason="JSON inválido")
skills = payload.get("skills") or []
if not isinstance(skills, list):
raise HTTPException(400, "skills deve ser uma lista")
raise web.HTTPBadRequest(reason="skills deve ser uma lista")
written: list[str] = []
for s in skills:
@ -66,11 +73,10 @@ def sync_skills(
target.write_text(body, encoding="utf-8")
written.append(name)
# Remove skills que não vieram no payload (sync completo, opcional)
if payload.get("prune"):
keep = set(written)
for f in SKILLS_DIR.glob("*.md"):
if f.stem not in keep:
f.unlink(missing_ok=True)
return {"written": written, "count": len(written)}
return web.json_response({"written": written, "count": len(written)})