Files
Agentic-OS/backend/app/services/integrations.py
nearxos 6185b9b85a Initial commit: Agentic OS troubleshooting platform
Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend +
LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini ->
DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials,
RBAC, audit log, project-memory + Obsidian integrations, and editable
troubleshooting decision rules tuned for the GeneseasX vessel stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:11:07 +03:00

354 lines
12 KiB
Python

"""Per-task persistence: project-memory (HTTP) and Obsidian vault (git)."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
from datetime import date
import httpx
from slugify import slugify
from app.config import settings
logger = logging.getLogger(__name__)
MCP_ACCEPT = "application/json, text/event-stream"
def _parse_mcp_response(resp: httpx.Response) -> dict:
"""Parse JSON or SSE body from streamable HTTP MCP responses."""
content_type = resp.headers.get("content-type", "")
text = resp.text.strip()
if "text/event-stream" in content_type or text.startswith("event:") or text.startswith("data:"):
for line in text.splitlines():
if line.startswith("data:"):
payload = line[5:].strip()
if payload and payload != "[DONE]":
return json.loads(payload)
return {}
if text:
return resp.json()
return {}
class _MemoryMcpSession:
"""Reusable streamable-HTTP session for the project-memory MCP server.
Performs the initialize handshake once and reuses the HTTP client +
Mcp-Session-Id across calls, re-initializing only when the session is
rejected (e.g. server restart / expired session).
"""
def __init__(self) -> None:
self._client: httpx.AsyncClient | None = None
self._session_id: str | None = None
self._lock = asyncio.Lock()
self._req_id = 0
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Accept": MCP_ACCEPT}
if settings.memory_mcp_token:
headers["Authorization"] = f"Bearer {settings.memory_mcp_token}"
if self._session_id:
headers["Mcp-Session-Id"] = self._session_id
return headers
async def _ensure_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=30)
return self._client
async def _handshake(self) -> None:
client = await self._ensure_client()
self._session_id = None
init_body = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "agentic-os", "version": "0.1.0"},
},
}
resp = await client.post(settings.memory_mcp_url, headers=self._headers(), json=init_body)
resp.raise_for_status()
self._session_id = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id")
await client.post(
settings.memory_mcp_url,
headers=self._headers(),
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
)
async def _call_once(self, tool_name: str, arguments: dict) -> dict | None:
client = await self._ensure_client()
self._req_id += 1
body = {
"jsonrpc": "2.0",
"id": self._req_id,
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments},
}
resp = await client.post(settings.memory_mcp_url, headers=self._headers(), json=body)
resp.raise_for_status()
data = _parse_mcp_response(resp)
if data.get("error"):
raise RuntimeError(data["error"].get("message", str(data["error"])))
return data.get("result")
async def call(self, tool_name: str, arguments: dict) -> dict | None:
async with self._lock:
if self._session_id is None:
await self._handshake()
try:
return await self._call_once(tool_name, arguments)
except (httpx.HTTPStatusError, RuntimeError) as exc:
# Session may have expired; re-handshake once and retry.
logger.info("memory MCP retry after error: %s", exc)
await self._handshake()
return await self._call_once(tool_name, arguments)
_memory_session = _MemoryMcpSession()
async def memory_mcp_call(tool_name: str, arguments: dict) -> dict | None:
"""Run one MCP tool call against project-memory over a reused HTTP session."""
if not settings.memory_mcp_url:
return None
return await _memory_session.call(tool_name, arguments)
async def create_task_memory(
title: str,
content: str,
memory_type: str = "fix",
tags: list[str] | None = None,
importance: int = 3,
*,
vessel_name: str | None = None,
task_id: int | None = None,
) -> str | None:
"""Call memory_upsert on the project-memory MCP over HTTP JSON-RPC."""
tag_list = list(tags or ["agentic-os"])
if vessel_name:
from slugify import slugify
tag_list.append(f"vessel:{slugify(vessel_name, lowercase=True)}")
if task_id is not None:
tag_list.append(f"task:{task_id}")
body_parts = []
if vessel_name:
body_parts.append(f"Vessel: {vessel_name}")
if task_id is not None:
body_parts.append(f"Task ID: {task_id}")
body_parts.append(content)
full_content = "\n\n".join(body_parts)
args = {
"project_id": settings.memory_task_project_id,
"memory_type": memory_type,
"title": title,
"content": full_content,
"tags": tag_list,
"importance": importance,
}
try:
result = await memory_mcp_call("memory_upsert", args)
if not isinstance(result, dict):
return None
structured = result.get("structuredContent")
if isinstance(structured, dict):
mid = structured.get("id") or structured.get("memory_id")
if mid:
return str(mid)
for block in result.get("content", []):
text = block.get("text") if isinstance(block, dict) else None
if text and "id" in text:
return text[:128]
except Exception as exc: # noqa: BLE001
logger.warning("create_task_memory failed: %s", exc)
return None
def _vault_dir() -> str:
return os.path.join(settings.runtime_dir, "obsidian-vault")
async def _run(cmd: list[str], cwd: str | None = None) -> tuple[int, str]:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=cwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
out, _ = await proc.communicate()
return proc.returncode or 0, out.decode(errors="replace")
async def _repair_vault_git(vault: str) -> None:
"""Recover from stuck rebase/merge so commits can be pushed to origin/main."""
git_dir = os.path.join(vault, ".git")
if not os.path.isdir(git_dir):
return
if os.path.isdir(os.path.join(git_dir, "rebase-merge")) or os.path.isdir(
os.path.join(git_dir, "rebase-apply")
):
logger.warning("Obsidian vault stuck in git rebase; attempting continue/abort")
code, _ = await _run(["git", "rebase", "--continue"], cwd=vault)
if code != 0:
await _run(["git", "rebase", "--abort"], cwd=vault)
if os.path.isfile(os.path.join(git_dir, "MERGE_HEAD")):
logger.warning("Obsidian vault stuck in git merge; aborting")
await _run(["git", "merge", "--abort"], cwd=vault)
code, _ = await _run(["git", "checkout", "main"], cwd=vault)
if code != 0:
await _run(["git", "checkout", "-B", "main"], cwd=vault)
async def _vault_pull(vault: str) -> None:
await _repair_vault_git(vault)
code, out = await _run(["git", "pull", "--ff-only", "origin", "main"], cwd=vault)
if code != 0:
logger.warning("vault ff-only pull failed: %s", out[:300])
await _repair_vault_git(vault)
await _run(["git", "pull", "--no-rebase", "origin", "main"], cwd=vault)
async def _vault_commit_and_push(vault: str, rel_path: str, title: str) -> tuple[bool, str | None]:
"""Stage, commit, and push one note. Returns (pushed, error_message)."""
if not settings.obsidian_auto_push or not settings.gitea_token:
return False, None
await _repair_vault_git(vault)
await _run(["git", "add", rel_path], cwd=vault)
code, out = await _run(
[
"git",
"-c",
"user.email=agentic-os@paraskeva.net",
"-c",
"user.name=Agentic OS",
"commit",
"-m",
f"agentic-os task: {title}",
],
cwd=vault,
)
if code != 0 and "nothing to commit" not in out.lower():
return False, out.strip()[:500]
await _repair_vault_git(vault)
code, out = await _run(["git", "push", "origin", "main"], cwd=vault)
if code != 0:
logger.warning("vault push failed: %s", out)
return False, out.strip()[:500]
return True, None
_VAULT_PULL_TTL = 300.0 # seconds between background `git pull` of the vault
_vault_pull_lock = asyncio.Lock()
_vault_last_pull = 0.0
async def ensure_vault(*, force_pull: bool = False) -> str | None:
global _vault_last_pull
vault = _vault_dir()
clone_url = settings.gitea_clone_url(settings.obsidian_vault_repo)
if os.path.isdir(os.path.join(vault, ".git")):
# Throttle pulls: skip if pulled recently (deduped across concurrent tasks).
now = time.monotonic()
if force_pull or now - _vault_last_pull >= _VAULT_PULL_TTL:
async with _vault_pull_lock:
if force_pull or time.monotonic() - _vault_last_pull >= _VAULT_PULL_TTL:
await _vault_pull(vault)
_vault_last_pull = time.monotonic()
return vault
os.makedirs(settings.runtime_dir, exist_ok=True)
code, out = await _run(["git", "clone", clone_url, vault])
if code != 0:
logger.warning("vault clone failed: %s", out)
return None
_vault_last_pull = time.monotonic()
return vault
async def write_obsidian_note(
title: str,
issue: str,
steps: list[str] | str,
cause: str | None,
resolution: str | None,
resolved: bool,
*,
vessel_name: str | None = None,
task_id: int | None = None,
diagnostics_md: str | None = None,
) -> tuple[str | None, str | None]:
"""Write task note to vault. Returns (relative_path, push_error)."""
vault = await ensure_vault()
if not vault:
return None, "Obsidian vault unavailable (clone failed)"
folder = settings.obsidian_tasks_folder
os.makedirs(os.path.join(vault, folder), exist_ok=True)
slug = slugify(title)[:60] or "task"
filename = f"{date.today().isoformat()}-{slug}.md"
rel_path = f"{folder}/{filename}"
abs_path = os.path.join(vault, rel_path)
steps_md = (
"\n".join(f"- {s}" for s in steps) if isinstance(steps, list) else str(steps or "")
)
status_label = "Resolved" if resolved else "Unresolved"
vessel_line = f"vessel: {vessel_name}\n" if vessel_name else ""
task_line = f"task_id: {task_id}\n" if task_id is not None else ""
diag_section = ""
if diagnostics_md:
diag_section = f"\n## Diagnostic findings\n{diagnostics_md}\n"
body = f"""---
tags: [agentic-os, troubleshooting]
{vessel_line}{task_line}status: {status_label.lower()}
created: {date.today().isoformat()}
---
# {title}
## Vessel
{vessel_name or "_Not specified._"}
## Issue
{issue}
## Troubleshooting steps
{steps_md}
## Root cause
{cause or "_Not determined._"}
## Resolution ({status_label})
{resolution or "_No resolution recorded._"}
{diag_section}"""
def _write() -> None:
with open(abs_path, "w", encoding="utf-8") as fh:
fh.write(body)
await asyncio.to_thread(_write)
pushed, push_error = await _vault_commit_and_push(vault, rel_path, title)
if push_error:
logger.warning("Obsidian note written locally at %s but git push failed: %s", rel_path, push_error)
elif not pushed and settings.obsidian_auto_push:
push_error = "Note saved in vault clone only (auto-push disabled or no Gitea token)"
return rel_path, push_error