Phase 3: approval gate, execute intent, approve/reject routes
- New: app/approval.py — HMAC-signed tokens, pending action store, 15-min TTL
- New: /approve/{token} and /reject/{token} GET routes (public, for Discord links)
- New: /internal/queue-action POST (for Citadel propose_file_change)
- New: execute intent in classifier — restart/rebuild + agent name queues action
- Updated: tools.py — notify_raven_with_actions, call_qyburn_rebuild/restart
- Updated: intent.py — EXECUTE_VERBS, STACK_NAMES, extract_execute_target
- Updated: SYSTEM_PROMPT reflects Phase 3 capabilities
- Updated: docker-compose.yml — JON_SECRET, JON_PUBLIC_URL, RAVEN_URL, QYBURN_URL
This commit is contained in:
+125
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -11,6 +12,9 @@ PLANE_URL = os.getenv("PLANE_URL", "http://172.27.40.3:8095")
|
||||
PLANE_WORKSPACE = os.getenv("PLANE_WORKSPACE", "nxm")
|
||||
PLANE_API_KEY = os.getenv("PLANE_API_KEY", "")
|
||||
|
||||
RAVEN_URL = os.getenv("RAVEN_URL", "http://raven-notify:8400")
|
||||
QYBURN_URL = os.getenv("QYBURN_URL", "http://qyburn-coder:8700")
|
||||
|
||||
AGENT_FULL_NAMES = [
|
||||
"hodor-gateway", "bran-changelog", "varys-monitor", "sam-research",
|
||||
"raven-notify", "qyburn-coder", "citadel-mcp", "jon-snow",
|
||||
@@ -63,6 +67,29 @@ def get_agent_output(sites_dir: Path, agent_name: str) -> str | None:
|
||||
return content[:3000]
|
||||
|
||||
|
||||
def _make_identifier(name: str) -> str:
|
||||
import re
|
||||
return re.sub(r"[^A-Z0-9]", "", name.upper())[:10] or "PROJ"
|
||||
|
||||
|
||||
async def create_plane_project(name: str) -> dict:
|
||||
identifier = _make_identifier(name)
|
||||
headers = {"X-Api-Key": PLANE_API_KEY, "Content-Type": "application/json"}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{PLANE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/",
|
||||
headers=headers,
|
||||
json={"name": name, "identifier": identifier, "network": 2},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
data = resp.json()
|
||||
raise ValueError(data.get("identifier", [data])[0] if "identifier" in data else str(data))
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {"name": data["name"], "identifier": data["identifier"]}
|
||||
|
||||
|
||||
async def get_plane_projects() -> list[dict]:
|
||||
headers = {"X-Api-Key": PLANE_API_KEY}
|
||||
async with httpx.AsyncClient() as client:
|
||||
@@ -74,6 +101,51 @@ async def get_plane_projects() -> list[dict]:
|
||||
return resp.json().get("results", [])
|
||||
|
||||
|
||||
async def get_plane_issues(project_hint: str) -> tuple[str, list[dict]]:
|
||||
"""Return (project_name, issues). Resolves project by name/identifier fragment."""
|
||||
projects = await get_plane_projects()
|
||||
hint = project_hint.lower()
|
||||
|
||||
project = None
|
||||
for p in projects:
|
||||
if p.get("identifier", "").lower() == hint:
|
||||
project = p
|
||||
break
|
||||
if not project:
|
||||
matches = [p for p in projects if hint in p["name"].lower()]
|
||||
if matches:
|
||||
project = matches[0]
|
||||
|
||||
if not project:
|
||||
return ("", [])
|
||||
|
||||
headers = {"X-Api-Key": PLANE_API_KEY}
|
||||
async with httpx.AsyncClient() as client:
|
||||
states_resp, issues_resp = await asyncio.gather(
|
||||
client.get(
|
||||
f"{PLANE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/{project['id']}/states/",
|
||||
headers=headers, timeout=10,
|
||||
),
|
||||
client.get(
|
||||
f"{PLANE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/{project['id']}/issues/",
|
||||
headers=headers, timeout=10,
|
||||
),
|
||||
)
|
||||
states_resp.raise_for_status()
|
||||
issues_resp.raise_for_status()
|
||||
|
||||
state_map = {s["id"]: s["name"] for s in states_resp.json().get("results", [])}
|
||||
issues = [
|
||||
{
|
||||
"title": i.get("name", ""),
|
||||
"state": state_map.get(i.get("state", ""), ""),
|
||||
"priority": i.get("priority", "none"),
|
||||
}
|
||||
for i in issues_resp.json().get("results", [])
|
||||
]
|
||||
return (project["name"], issues)
|
||||
|
||||
|
||||
def _resolve_project(projects: list[dict], hint: str | None) -> dict:
|
||||
if hint:
|
||||
hint_lower = hint.lower()
|
||||
@@ -110,3 +182,56 @@ async def create_plane_issue(title: str, project_hint: str | None = None) -> dic
|
||||
"title": title[:80],
|
||||
"project": project["name"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Approval gate helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def notify_raven_with_actions(
|
||||
description: str,
|
||||
approve_url: str,
|
||||
reject_url: str,
|
||||
action_type: str,
|
||||
) -> None:
|
||||
"""Send a Discord approval request via Raven's /notify-with-actions endpoint."""
|
||||
payload = {
|
||||
"description": description,
|
||||
"approve_url": approve_url,
|
||||
"reject_url": reject_url,
|
||||
"action_type": action_type,
|
||||
"source": "jon-snow",
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{RAVEN_URL}/notify-with-actions",
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"raven notified for action: {action_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"raven notify failed: {e}")
|
||||
|
||||
|
||||
async def call_qyburn_rebuild(stack_name: str) -> dict:
|
||||
"""Trigger a docker rebuild (compose up -d --build) via Qyburn."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{QYBURN_URL}/rebuild/{stack_name}",
|
||||
timeout=300, # builds can take a while
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def call_qyburn_restart(stack_name: str) -> dict:
|
||||
"""Trigger a docker restart (compose restart) via Qyburn."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{QYBURN_URL}/restart/{stack_name}",
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
Reference in New Issue
Block a user