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:
+108
@@ -0,0 +1,108 @@
|
|||||||
|
"""Jon Snow approval gate — token management and pending action store."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
JON_SECRET = os.getenv("JON_SECRET", "change-me")
|
||||||
|
TOKEN_TTL = 900 # 15 minutes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingAction:
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
action_type: str # "docker_rebuild", "docker_restart", "file_write"
|
||||||
|
params: dict
|
||||||
|
plane_issue_id: Optional[str] = None
|
||||||
|
plane_project_id: Optional[str] = None
|
||||||
|
created_at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
|
_pending: dict[str, PendingAction] = {}
|
||||||
|
_used_tokens: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def queue_action(
|
||||||
|
description: str,
|
||||||
|
action_type: str,
|
||||||
|
params: dict,
|
||||||
|
plane_issue_id: Optional[str] = None,
|
||||||
|
plane_project_id: Optional[str] = None,
|
||||||
|
) -> PendingAction:
|
||||||
|
"""Create and store a pending action. Returns the action with a fresh UUID."""
|
||||||
|
action = PendingAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
description=description,
|
||||||
|
action_type=action_type,
|
||||||
|
params=params,
|
||||||
|
plane_issue_id=plane_issue_id,
|
||||||
|
plane_project_id=plane_project_id,
|
||||||
|
)
|
||||||
|
_pending[action.id] = action
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token(action_id: str, purpose: str) -> str:
|
||||||
|
"""Generate a signed, time-stamped base64url token for approve or reject."""
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
msg = f"{action_id}:{ts}:{purpose}".encode()
|
||||||
|
sig = hmac.new(JON_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:20]
|
||||||
|
raw = f"{action_id}:{ts}:{purpose}:{sig}"
|
||||||
|
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _pad(s: str) -> str:
|
||||||
|
return s + "=" * (4 - len(s) % 4)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str) -> tuple[str, str]:
|
||||||
|
"""Decode and verify a token. Returns (action_id, purpose).
|
||||||
|
Raises ValueError on expired, used, or invalid signature."""
|
||||||
|
try:
|
||||||
|
raw = base64.urlsafe_b64decode(_pad(token)).decode()
|
||||||
|
parts = raw.split(":")
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError("malformed token")
|
||||||
|
action_id, ts, purpose, sig = parts
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("invalid token format")
|
||||||
|
|
||||||
|
if token in _used_tokens:
|
||||||
|
raise ValueError("token already used")
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
if now - int(ts) > TOKEN_TTL:
|
||||||
|
raise ValueError("token expired")
|
||||||
|
|
||||||
|
# Verify HMAC
|
||||||
|
msg = f"{action_id}:{ts}:{purpose}".encode()
|
||||||
|
expected_sig = hmac.new(JON_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:20]
|
||||||
|
if not hmac.compare_digest(sig, expected_sig):
|
||||||
|
raise ValueError("invalid token signature")
|
||||||
|
|
||||||
|
_used_tokens.add(token)
|
||||||
|
return action_id, purpose
|
||||||
|
|
||||||
|
|
||||||
|
def pop_action(action_id: str) -> Optional[PendingAction]:
|
||||||
|
"""Remove and return a pending action by ID. Returns None if not found."""
|
||||||
|
return _pending.pop(action_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_action(action_id: str) -> Optional[PendingAction]:
|
||||||
|
"""Get a pending action without removing it."""
|
||||||
|
return _pending.get(action_id)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired() -> int:
|
||||||
|
"""Remove actions older than TOKEN_TTL. Returns count removed."""
|
||||||
|
cutoff = time.time() - TOKEN_TTL
|
||||||
|
expired = [aid for aid, a in _pending.items() if a.created_at < cutoff]
|
||||||
|
for aid in expired:
|
||||||
|
del _pending[aid]
|
||||||
|
return len(expired)
|
||||||
+189
-3
@@ -1,4 +1,4 @@
|
|||||||
AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon"}
|
AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon", "hermes"}
|
||||||
|
|
||||||
STATUS_PHRASES = {
|
STATUS_PHRASES = {
|
||||||
"status", "health", "running", "last run", "what did", "when did",
|
"status", "health", "running", "last run", "what did", "when did",
|
||||||
@@ -9,7 +9,17 @@ STATUS_PHRASES = {
|
|||||||
TASK_PHRASES = {
|
TASK_PHRASES = {
|
||||||
"create task", "add task", "add issue", "create issue", "log task",
|
"create task", "add task", "add issue", "create issue", "log task",
|
||||||
"log this", "new task", "new issue", "add to plane", "add to backlog",
|
"log this", "new task", "new issue", "add to plane", "add to backlog",
|
||||||
"plan", "schedule", "remind", "track", "todo", "to do",
|
"schedule", "remind", "track", "todo", "to do",
|
||||||
|
}
|
||||||
|
|
||||||
|
# "plan" removed from TASK_PHRASES — it substring-matches "plane" (the tool name)
|
||||||
|
TASK_WORDS = {"plan"}
|
||||||
|
|
||||||
|
QUERY_PHRASES = {
|
||||||
|
"list", "list all", "show all", "what are", "give me", "get",
|
||||||
|
"my projects", "all projects", "list projects", "show projects",
|
||||||
|
"issues", "tasks", "backlog", "what's in", "whats in", "show issues",
|
||||||
|
"list issues", "issues in", "tasks in", "work items", "work item",
|
||||||
}
|
}
|
||||||
|
|
||||||
RESEARCH_PHRASES = {
|
RESEARCH_PHRASES = {
|
||||||
@@ -17,12 +27,61 @@ RESEARCH_PHRASES = {
|
|||||||
"how does", "documentation", "docs",
|
"how does", "documentation", "docs",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CREATE_PROJECT_PHRASES = {
|
||||||
|
"create project", "new project", "make project", "add project",
|
||||||
|
"create a project", "new project called", "create project called",
|
||||||
|
"start a project", "set up a project",
|
||||||
|
"create a plane project", "create plane project",
|
||||||
|
"create a new project", "make a new project",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute intent — words that signal a request to run/deploy a container
|
||||||
|
EXECUTE_VERBS = {"restart", "rebuild", "redeploy", "reload", "deploy"}
|
||||||
|
|
||||||
|
# Short agent name → Docker stack directory name
|
||||||
|
STACK_NAMES = {
|
||||||
|
"hodor": "hodor-gateway",
|
||||||
|
"bran": "bran-changelog",
|
||||||
|
"varys": "varys-monitor",
|
||||||
|
"sam": "sam-research",
|
||||||
|
"raven": "raven-notify",
|
||||||
|
"qyburn": "qyburn-coder",
|
||||||
|
"citadel": "citadel-mcp",
|
||||||
|
"jon": "jon-snow",
|
||||||
|
"hermes": "hermes-cloud",
|
||||||
|
"monitoring": "monitoring",
|
||||||
|
"netbox": "netbox",
|
||||||
|
"plane": "plane",
|
||||||
|
"gitea": "gitea",
|
||||||
|
"searxng": "searxng",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def classify_intent(message: str) -> str:
|
def classify_intent(message: str) -> str:
|
||||||
msg = message.lower()
|
msg = message.lower()
|
||||||
words = set(msg.split())
|
words = set(msg.split())
|
||||||
|
|
||||||
# Agent name + query word → status
|
# Project creation — check before task/query
|
||||||
|
if any(p in msg for p in CREATE_PROJECT_PHRASES):
|
||||||
|
return "create_project"
|
||||||
|
|
||||||
|
# Execute intent — agent/stack name + action verb → queue for approval
|
||||||
|
if words & EXECUTE_VERBS and words & set(STACK_NAMES.keys()):
|
||||||
|
return "execute"
|
||||||
|
if any(v in msg for v in EXECUTE_VERBS) and any(s in msg for s in STACK_NAMES):
|
||||||
|
return "execute"
|
||||||
|
|
||||||
|
# Plane query — list/show projects or issues (check before task to avoid "plan" ⊂ "plane" bug)
|
||||||
|
if ("project" in msg or "projects" in msg) and any(p in msg for p in QUERY_PHRASES):
|
||||||
|
return "query"
|
||||||
|
if "plane" in words and any(p in msg for p in QUERY_PHRASES):
|
||||||
|
return "query"
|
||||||
|
# Issue list query — "show issues in X", "list tasks for X", "work items for X"
|
||||||
|
has_issue_word = bool(words & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES)
|
||||||
|
if has_issue_word and any(p in msg for p in {"show", "list", "get", "what", "give", "in", "for"}):
|
||||||
|
return "query"
|
||||||
|
|
||||||
|
# Agent name + status word → status
|
||||||
if words & AGENT_NAMES:
|
if words & AGENT_NAMES:
|
||||||
if any(p in msg for p in STATUS_PHRASES) or words & {"status", "check", "output", "run"}:
|
if any(p in msg for p in STATUS_PHRASES) or words & {"status", "check", "output", "run"}:
|
||||||
return "status"
|
return "status"
|
||||||
@@ -30,6 +89,12 @@ def classify_intent(message: str) -> str:
|
|||||||
# Explicit task phrases → task
|
# Explicit task phrases → task
|
||||||
if any(p in msg for p in TASK_PHRASES):
|
if any(p in msg for p in TASK_PHRASES):
|
||||||
return "task"
|
return "task"
|
||||||
|
# Word-boundary match for single-word task terms (avoids "plan" matching "plane")
|
||||||
|
if words & TASK_WORDS:
|
||||||
|
return "task"
|
||||||
|
# Natural task creation: "add X to [project]"
|
||||||
|
if msg.startswith("add ") and " to " in msg:
|
||||||
|
return "task"
|
||||||
|
|
||||||
# Generic status signal words
|
# Generic status signal words
|
||||||
if any(p in msg for p in STATUS_PHRASES) and words & AGENT_NAMES:
|
if any(p in msg for p in STATUS_PHRASES) and words & AGENT_NAMES:
|
||||||
@@ -54,6 +119,36 @@ def extract_agent_name(message: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_execute_target(message: str) -> tuple[str, str] | None:
|
||||||
|
"""Extract (action_type, stack_dir_name) from an execute message.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
'restart citadel' → ('docker_restart', 'citadel-mcp')
|
||||||
|
'rebuild raven' → ('docker_rebuild', 'raven-notify')
|
||||||
|
'redeploy hermes' → ('docker_rebuild', 'hermes-cloud')
|
||||||
|
|
||||||
|
Returns None if action type or stack name cannot be determined.
|
||||||
|
"""
|
||||||
|
msg = message.lower()
|
||||||
|
words = set(msg.split())
|
||||||
|
|
||||||
|
action_type = None
|
||||||
|
if words & {"restart", "reload"}:
|
||||||
|
action_type = "docker_restart"
|
||||||
|
elif words & {"rebuild", "redeploy", "deploy"}:
|
||||||
|
action_type = "docker_rebuild"
|
||||||
|
|
||||||
|
stack_dir = None
|
||||||
|
for short, full in STACK_NAMES.items():
|
||||||
|
if short in words:
|
||||||
|
stack_dir = full
|
||||||
|
break
|
||||||
|
|
||||||
|
if action_type and stack_dir:
|
||||||
|
return (action_type, stack_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
PROJECT_KEYWORDS = {
|
PROJECT_KEYWORDS = {
|
||||||
"bni": "BNI Scheduler",
|
"bni": "BNI Scheduler",
|
||||||
"scheduler": "BNI Scheduler",
|
"scheduler": "BNI Scheduler",
|
||||||
@@ -67,9 +162,100 @@ PROJECT_KEYWORDS = {
|
|||||||
"portal": "Nexum Portal",
|
"portal": "Nexum Portal",
|
||||||
"authelia": "Nexum Portal",
|
"authelia": "Nexum Portal",
|
||||||
"nexum": "Nexum Portal",
|
"nexum": "Nexum Portal",
|
||||||
|
"general": "General / Admin",
|
||||||
|
"admin": "General / Admin",
|
||||||
|
"agent": "Agent Ecosystem",
|
||||||
|
"ecosystem": "Agent Ecosystem",
|
||||||
|
"milkwood": "Milkwood Primary",
|
||||||
|
"grgl": "GRGL",
|
||||||
|
"private": "Private Clients",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ISSUE_KEYWORDS = {"issue", "issues", "task", "tasks", "backlog", "ticket", "tickets"}
|
||||||
|
ISSUE_PHRASES = {"work items", "work item"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_issue_query(message: str) -> bool:
|
||||||
|
msg = message.lower()
|
||||||
|
return bool(set(msg.split()) & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_task_title(message: str) -> str:
|
||||||
|
"""Extract clean task title from natural phrasing like 'add buy milk to General'."""
|
||||||
|
msg = message.strip()
|
||||||
|
lower = msg.lower()
|
||||||
|
if lower.startswith("add ") and " to " in lower:
|
||||||
|
parts = msg.rsplit(" to ", 1)
|
||||||
|
title = parts[0][4:].strip()
|
||||||
|
return title if title else msg
|
||||||
|
for prefix in ("create task:", "add task:", "log task:", "new task:", "log this:"):
|
||||||
|
if lower.startswith(prefix):
|
||||||
|
return msg[len(prefix):].strip()
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def extract_compound_task(message: str) -> str | None:
|
||||||
|
"""Extract a task title from compound messages like 'create project X and add [todo item,] Y'.
|
||||||
|
Returns the task title or None if no compound task found."""
|
||||||
|
lower = message.lower()
|
||||||
|
# Split on compound markers
|
||||||
|
for marker in ("and add to do item,", "and add todo item,", "and add a task,",
|
||||||
|
"and add task,", "and add issue,", "and add to do,",
|
||||||
|
"and add to-do,", "and add,"):
|
||||||
|
idx = lower.find(marker)
|
||||||
|
if idx != -1:
|
||||||
|
return message[idx + len(marker):].strip().strip('"\'')
|
||||||
|
# Simpler: "and add [title]" — only if no comma separator
|
||||||
|
idx = lower.find(" and add ")
|
||||||
|
if idx != -1:
|
||||||
|
candidate = message[idx + 9:].strip()
|
||||||
|
# Skip if it looks like a project destination ("and add to X")
|
||||||
|
if not candidate.lower().startswith("to "):
|
||||||
|
return candidate.strip('"\'')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_at_compound(text: str) -> str:
|
||||||
|
"""Trim text at compound boundaries like 'and add'."""
|
||||||
|
lower = text.lower()
|
||||||
|
for stop in (" and add", " then add", ", and", " also add"):
|
||||||
|
idx = lower.find(stop)
|
||||||
|
if idx != -1:
|
||||||
|
text = text[:idx]
|
||||||
|
return text.strip().strip('"\',.')
|
||||||
|
|
||||||
|
|
||||||
|
def extract_new_project_name(message: str) -> str | None:
|
||||||
|
"""Extract project name from 'create project called X', 'new project: X', etc."""
|
||||||
|
msg = message.strip()
|
||||||
|
lower = msg.lower()
|
||||||
|
for marker in ("called ", "named ", "name ", ": ", "- "):
|
||||||
|
idx = lower.find(marker)
|
||||||
|
if idx != -1:
|
||||||
|
name = _trim_at_compound(msg[idx + len(marker):])
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
for prefix in ("create a new project ", "create a plane project ", "create plane project ",
|
||||||
|
"create a project ", "create project ", "new project ", "make a new project ",
|
||||||
|
"make project ", "add project ", "start a project ", "set up a project "):
|
||||||
|
if lower.startswith(prefix):
|
||||||
|
name = _trim_at_compound(msg[len(prefix):])
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_task_destination(message: str) -> str | None:
|
||||||
|
"""Extract the destination project from 'add X to Y' → returns Y, or None."""
|
||||||
|
lower = message.strip().lower()
|
||||||
|
if lower.startswith("add ") and " to " in lower:
|
||||||
|
parts = message.strip().rsplit(" to ", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
return parts[1].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def extract_project_name(message: str) -> str | None:
|
def extract_project_name(message: str) -> str | None:
|
||||||
msg = message.lower()
|
msg = message.lower()
|
||||||
for kw, project in PROJECT_KEYWORDS.items():
|
for kw, project in PROJECT_KEYWORDS.items():
|
||||||
|
|||||||
+284
-13
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -12,17 +13,39 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .approval import cleanup_expired, generate_token, pop_action, queue_action, verify_token
|
||||||
from .brain import stream_completion
|
from .brain import stream_completion
|
||||||
from .intent import classify_intent, extract_agent_name, extract_project_name
|
from .intent import (
|
||||||
from .tools import create_plane_issue, get_agent_output, get_all_agent_status
|
classify_intent,
|
||||||
|
extract_agent_name,
|
||||||
|
extract_compound_task,
|
||||||
|
extract_execute_target,
|
||||||
|
extract_new_project_name,
|
||||||
|
extract_project_name,
|
||||||
|
extract_task_destination,
|
||||||
|
extract_task_title,
|
||||||
|
is_issue_query,
|
||||||
|
)
|
||||||
|
from .tools import (
|
||||||
|
call_qyburn_rebuild,
|
||||||
|
call_qyburn_restart,
|
||||||
|
create_plane_issue,
|
||||||
|
create_plane_project,
|
||||||
|
get_agent_output,
|
||||||
|
get_all_agent_status,
|
||||||
|
get_plane_issues,
|
||||||
|
get_plane_projects,
|
||||||
|
notify_raven_with_actions,
|
||||||
|
)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
logger = logging.getLogger("jon-snow")
|
logger = logging.getLogger("jon-snow")
|
||||||
|
|
||||||
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
|
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
|
||||||
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites"))
|
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites"))
|
||||||
|
JON_PUBLIC_URL = os.getenv("JON_PUBLIC_URL", "https://jon.nxm.co.za")
|
||||||
|
|
||||||
app = FastAPI(title="Jon Snow", version="0.2.0")
|
app = FastAPI(title="Jon Snow", version="0.3.0")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You are Jon Snow, chief of staff for the NxM home lab agent ecosystem on a self-hosted Linux server (172.27.40.3).
|
SYSTEM_PROMPT = """You are Jon Snow, chief of staff for the NxM home lab agent ecosystem on a self-hosted Linux server (172.27.40.3).
|
||||||
@@ -34,17 +57,19 @@ Live agents you coordinate:
|
|||||||
- sam (8500): Research agent — SearXNG + Ollama synthesis
|
- sam (8500): Research agent — SearXNG + Ollama synthesis
|
||||||
- raven (8400): Notifications — Discord webhook + Gmail SMTP
|
- raven (8400): Notifications — Discord webhook + Gmail SMTP
|
||||||
- qyburn (8700): LLM coding agent — qwen2.5-coder:14b, approve/reject workflow
|
- qyburn (8700): LLM coding agent — qwen2.5-coder:14b, approve/reject workflow
|
||||||
- citadel (8300): MCP tool registry — 16 tools including Plane integration
|
- citadel (8300): MCP tool registry — 21 tools including Plane integration
|
||||||
|
- hermes: Cloud intelligence agent — claude-sonnet-4-6 brain, connected to Citadel read-only
|
||||||
|
|
||||||
Infrastructure: Ubuntu Docker host 172.27.40.3, Ollama at 172.27.40.20:11434, Netbird VPN (100.119.x.x), Plane project management, Gitea at git.nxm.co.za.
|
Infrastructure: Ubuntu Docker host 172.27.40.3, Ollama at 172.27.40.20:11434, Headscale VPN, Plane project management, Gitea at git.nxm.co.za.
|
||||||
|
|
||||||
Your current capabilities (Phase 2):
|
Your capabilities (Phase 3):
|
||||||
1. Report agent status — last run time, success/failure, output summary
|
1. Report agent status — last run time, success/failure, output summary
|
||||||
2. Log tasks to Plane project management
|
2. List Plane projects and log tasks to Plane project management
|
||||||
3. Answer questions about the infrastructure
|
3. Answer questions about the infrastructure
|
||||||
4. Route complex questions to your SMART_MODEL brain
|
4. Route complex questions to your SMART_MODEL brain
|
||||||
|
5. Queue execution actions (restart/rebuild agents) — sends approval request via Discord before executing
|
||||||
|
|
||||||
You cannot yet execute tasks autonomously — that is Phase 3. When a user submits a task, log it to Plane and confirm.
|
For execution requests (restart/rebuild/redeploy), always queue for approval — never execute directly.
|
||||||
Be concise — the user is often on mobile. Use short markdown lists, not long paragraphs."""
|
Be concise — the user is often on mobile. Use short markdown lists, not long paragraphs."""
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +88,12 @@ class ChatRequest(BaseModel):
|
|||||||
max_tokens: int | None = None
|
max_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class QueueActionRequest(BaseModel):
|
||||||
|
description: str
|
||||||
|
action_type: str
|
||||||
|
params: dict
|
||||||
|
|
||||||
|
|
||||||
# --- Output helpers ---
|
# --- Output helpers ---
|
||||||
|
|
||||||
def _write_status(intent: str, summary: str, status: str = "success") -> None:
|
def _write_status(intent: str, summary: str, status: str = "success") -> None:
|
||||||
@@ -142,7 +173,7 @@ async def _stream_llm(messages: list[dict], use_smart: bool = False) -> AsyncGen
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "agent": "jon-snow", "version": "0.2.0"}
|
return {"status": "ok", "agent": "jon-snow", "version": "0.3.0"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/models")
|
@app.get("/v1/models")
|
||||||
@@ -153,6 +184,123 @@ async def list_models():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/approve/{token}")
|
||||||
|
async def approve_action(token: str):
|
||||||
|
"""Public endpoint — called when user clicks Approve in Discord. Executes the queued action."""
|
||||||
|
cleanup_expired()
|
||||||
|
try:
|
||||||
|
action_id, purpose = verify_token(token)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": str(e), "message": "Approval failed — token may be expired or already used."},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if purpose != "approve":
|
||||||
|
return JSONResponse({"error": "wrong token purpose"}, status_code=400)
|
||||||
|
|
||||||
|
action = pop_action(action_id)
|
||||||
|
if not action:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "action not found", "message": "Action not found — it may have already been executed or expired."},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"approved: {action.action_type} — {action.description}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action.action_type == "docker_rebuild":
|
||||||
|
result = await call_qyburn_rebuild(action.params["stack_name"])
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"message": f"✅ Rebuilding {action.params['stack_name']}. Check container logs for progress.",
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
elif action.action_type == "docker_restart":
|
||||||
|
result = await call_qyburn_restart(action.params["stack_name"])
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"message": f"✅ Restarting {action.params['stack_name']}.",
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
elif action.action_type == "file_write":
|
||||||
|
path = action.params.get("path", "")
|
||||||
|
content = action.params.get("content", "")
|
||||||
|
safe_path = re.sub(r"\.\.", "", path).lstrip("/")
|
||||||
|
target = SITES_DIR / safe_path
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(content)
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"message": f"✅ File written: /opt/sites/{safe_path}",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": f"Unknown action type: {action.action_type}"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"action execution failed: {e}")
|
||||||
|
return JSONResponse({"error": f"Execution failed: {e}"}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reject/{token}")
|
||||||
|
async def reject_action(token: str):
|
||||||
|
"""Public endpoint — called when user clicks Reject in Discord. Discards the queued action."""
|
||||||
|
cleanup_expired()
|
||||||
|
try:
|
||||||
|
action_id, purpose = verify_token(token)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": str(e), "message": "Rejection failed — token may be expired or already used."},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if purpose != "reject":
|
||||||
|
return JSONResponse({"error": "wrong token purpose"}, status_code=400)
|
||||||
|
|
||||||
|
action = pop_action(action_id)
|
||||||
|
if not action:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "action not found", "message": "Action not found — it may have already been handled."},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"rejected: {action.action_type} — {action.description}")
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"message": f"❌ Action rejected: {action.description}",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/queue-action")
|
||||||
|
async def internal_queue_action(req: QueueActionRequest):
|
||||||
|
"""Internal endpoint — called by Citadel's propose_file_change tool to queue a file write for approval."""
|
||||||
|
action = queue_action(
|
||||||
|
description=req.description,
|
||||||
|
action_type=req.action_type,
|
||||||
|
params=req.params,
|
||||||
|
)
|
||||||
|
|
||||||
|
approve_url = f"{JON_PUBLIC_URL}/approve/{generate_token(action.id, 'approve')}"
|
||||||
|
reject_url = f"{JON_PUBLIC_URL}/reject/{generate_token(action.id, 'reject')}"
|
||||||
|
|
||||||
|
await notify_raven_with_actions(
|
||||||
|
description=req.description,
|
||||||
|
approve_url=approve_url,
|
||||||
|
reject_url=reject_url,
|
||||||
|
action_type=req.action_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"queued from citadel: {req.action_type} — {req.description}")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"action_id": action.id,
|
||||||
|
"approve_url": approve_url,
|
||||||
|
"reject_url": reject_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/chat/completions")
|
@app.post("/v1/chat/completions")
|
||||||
async def chat_completions(req: ChatRequest):
|
async def chat_completions(req: ChatRequest):
|
||||||
user_message = next((m.content for m in reversed(req.messages) if m.role == "user"), "")
|
user_message = next((m.content for m in reversed(req.messages) if m.role == "user"), "")
|
||||||
@@ -178,16 +326,139 @@ async def chat_completions(req: ChatRequest):
|
|||||||
yield chunk
|
yield chunk
|
||||||
summary = f"Status query: {user_message[:100]}"
|
summary = f"Status query: {user_message[:100]}"
|
||||||
|
|
||||||
|
elif intent == "execute":
|
||||||
|
target = extract_execute_target(user_message)
|
||||||
|
if not target:
|
||||||
|
response_text = "I couldn't identify what to execute. Try: _restart citadel_ or _rebuild raven_."
|
||||||
|
summary = "execute: no target extracted"
|
||||||
|
else:
|
||||||
|
action_type, stack_name = target
|
||||||
|
verb = "Restart" if action_type == "docker_restart" else "Rebuild"
|
||||||
|
description = f"{verb} {stack_name}"
|
||||||
|
|
||||||
|
action = queue_action(
|
||||||
|
description=description,
|
||||||
|
action_type=action_type,
|
||||||
|
params={"stack_name": stack_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
approve_url = f"{JON_PUBLIC_URL}/approve/{generate_token(action.id, 'approve')}"
|
||||||
|
reject_url = f"{JON_PUBLIC_URL}/reject/{generate_token(action.id, 'reject')}"
|
||||||
|
|
||||||
|
await notify_raven_with_actions(
|
||||||
|
description=description,
|
||||||
|
approve_url=approve_url,
|
||||||
|
reject_url=reject_url,
|
||||||
|
action_type=action_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = (
|
||||||
|
f"⚡ Approval requested for: **{description}**\n\n"
|
||||||
|
f"A Discord notification has been sent with approve/reject links.\n"
|
||||||
|
f"Token expires in 15 minutes."
|
||||||
|
)
|
||||||
|
summary = f"execute queued: {description}"
|
||||||
|
|
||||||
|
async for chunk in _stream_text(response_text):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
elif intent == "create_project":
|
||||||
|
name = extract_new_project_name(user_message)
|
||||||
|
if not name:
|
||||||
|
response_text = "What should the project be called?"
|
||||||
|
summary = "create_project: no name extracted"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
project = await create_plane_project(name)
|
||||||
|
response_text = (
|
||||||
|
f"Project created.\n\n"
|
||||||
|
f"**{project['name']}** (`{project['identifier']}`)"
|
||||||
|
)
|
||||||
|
summary = f"Created project: {project['name']}"
|
||||||
|
# Handle compound: "create project X and add [task]"
|
||||||
|
task_title = extract_compound_task(user_message)
|
||||||
|
if task_title:
|
||||||
|
issue = await create_plane_issue(task_title, project["name"])
|
||||||
|
response_text += (
|
||||||
|
f"\n\n**{issue['title']}** added.\n"
|
||||||
|
f"Project: *{issue['project']}* | #{issue['sequence_id']}"
|
||||||
|
)
|
||||||
|
summary += f" + issue: {task_title[:60]}"
|
||||||
|
else:
|
||||||
|
response_text += f"\n\nAdd issues with: _add [task] to {project['name']}_"
|
||||||
|
except Exception as e:
|
||||||
|
response_text = f"Couldn't create project: {e}"
|
||||||
|
summary = f"create_project error: {e}"
|
||||||
|
async for chunk in _stream_text(response_text):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
elif intent == "query":
|
||||||
|
try:
|
||||||
|
if is_issue_query(user_message):
|
||||||
|
# Match against live project list — most robust, no static keywords needed
|
||||||
|
projects = await get_plane_projects()
|
||||||
|
msg_lower = user_message.lower()
|
||||||
|
project = None
|
||||||
|
# Longest-name-first so "General / Admin" beats "General"
|
||||||
|
for p in sorted(projects, key=lambda x: len(x["name"]), reverse=True):
|
||||||
|
name_words = p["name"].lower().replace("/", " ").replace("-", " ")
|
||||||
|
# Match if all significant words of the project name appear in the message
|
||||||
|
sig_words = [w for w in name_words.split() if len(w) > 2]
|
||||||
|
if sig_words and all(w in msg_lower for w in sig_words):
|
||||||
|
project = p
|
||||||
|
break
|
||||||
|
# Or if the identifier appears
|
||||||
|
if p.get("identifier", "").lower() in msg_lower:
|
||||||
|
project = p
|
||||||
|
break
|
||||||
|
if not project:
|
||||||
|
# Fall back to static keyword map
|
||||||
|
hint = extract_project_name(user_message)
|
||||||
|
if hint:
|
||||||
|
_, _ = await get_plane_issues(hint) # resolve via tools
|
||||||
|
project_name_fb, issues_fb = await get_plane_issues(hint)
|
||||||
|
if project_name_fb:
|
||||||
|
project = {"id": None, "name": project_name_fb}
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
names = "\n".join(f"- {p['name']}" for p in projects)
|
||||||
|
response_text = f"Which project? Available:\n\n{names}"
|
||||||
|
else:
|
||||||
|
project_name, issues = await get_plane_issues(project["name"])
|
||||||
|
if not issues:
|
||||||
|
response_text = f"**{project['name']}** — no issues found."
|
||||||
|
else:
|
||||||
|
PRIORITY_ICON = {"urgent": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "none": "⚪"}
|
||||||
|
lines = "\n".join(
|
||||||
|
f"- [{i['state']}] {PRIORITY_ICON.get(i['priority'], '')} {i['title']}"
|
||||||
|
for i in issues
|
||||||
|
)
|
||||||
|
response_text = f"**{project['name']} — {len(issues)} issue(s):**\n\n{lines}"
|
||||||
|
summary = f"Issues query: {user_message[:80]}"
|
||||||
|
else:
|
||||||
|
projects = await get_plane_projects()
|
||||||
|
lines = "\n".join(
|
||||||
|
f"- **{p.get('identifier', '?')}** — {p['name']}"
|
||||||
|
for p in projects
|
||||||
|
)
|
||||||
|
response_text = f"**Plane Projects ({len(projects)}):**\n\n{lines}"
|
||||||
|
summary = f"Listed {len(projects)} Plane projects"
|
||||||
|
except Exception as e:
|
||||||
|
response_text = f"Couldn't reach Plane: {e}"
|
||||||
|
summary = f"Plane query error: {e}"
|
||||||
|
async for chunk in _stream_text(response_text):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
elif intent == "task":
|
elif intent == "task":
|
||||||
project_hint = extract_project_name(user_message)
|
# Live destination match first, static keyword map as fallback
|
||||||
title = user_message.strip()
|
project_hint = extract_task_destination(user_message) or extract_project_name(user_message)
|
||||||
|
title = extract_task_title(user_message)
|
||||||
try:
|
try:
|
||||||
issue = await create_plane_issue(title, project_hint)
|
issue = await create_plane_issue(title, project_hint)
|
||||||
response_text = (
|
response_text = (
|
||||||
f"Task logged to Plane.\n\n"
|
f"Task logged to Plane.\n\n"
|
||||||
f"**{issue['title']}** \n"
|
f"**{issue['title']}** \n"
|
||||||
f"Project: *{issue['project']}* | #{issue['sequence_id']}\n\n"
|
f"Project: *{issue['project']}* | #{issue['sequence_id']}"
|
||||||
f"I can't execute tasks yet (Phase 3). It's in the backlog."
|
|
||||||
)
|
)
|
||||||
summary = f"Task created: {issue['title']}"
|
summary = f"Task created: {issue['title']}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+125
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
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_WORKSPACE = os.getenv("PLANE_WORKSPACE", "nxm")
|
||||||
PLANE_API_KEY = os.getenv("PLANE_API_KEY", "")
|
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 = [
|
AGENT_FULL_NAMES = [
|
||||||
"hodor-gateway", "bran-changelog", "varys-monitor", "sam-research",
|
"hodor-gateway", "bran-changelog", "varys-monitor", "sam-research",
|
||||||
"raven-notify", "qyburn-coder", "citadel-mcp", "jon-snow",
|
"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]
|
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]:
|
async def get_plane_projects() -> list[dict]:
|
||||||
headers = {"X-Api-Key": PLANE_API_KEY}
|
headers = {"X-Api-Key": PLANE_API_KEY}
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
@@ -74,6 +101,51 @@ async def get_plane_projects() -> list[dict]:
|
|||||||
return resp.json().get("results", [])
|
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:
|
def _resolve_project(projects: list[dict], hint: str | None) -> dict:
|
||||||
if hint:
|
if hint:
|
||||||
hint_lower = hint.lower()
|
hint_lower = hint.lower()
|
||||||
@@ -110,3 +182,56 @@ async def create_plane_issue(title: str, project_hint: str | None = None) -> dic
|
|||||||
"title": title[:80],
|
"title": title[:80],
|
||||||
"project": project["name"],
|
"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()
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ services:
|
|||||||
PLANE_API_KEY: ${PLANE_API_KEY}
|
PLANE_API_KEY: ${PLANE_API_KEY}
|
||||||
AGENT_OS_DIR: /opt/agent-os
|
AGENT_OS_DIR: /opt/agent-os
|
||||||
SITES_DIR: /opt/sites
|
SITES_DIR: /opt/sites
|
||||||
|
RAVEN_URL: ${RAVEN_URL:-http://raven-notify:8400}
|
||||||
|
QYBURN_URL: ${QYBURN_URL:-http://qyburn-coder:8700}
|
||||||
|
JON_PUBLIC_URL: ${JON_PUBLIC_URL:-https://jon.nxm.co.za}
|
||||||
|
JON_SECRET: ${JON_SECRET}
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user