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>
354 lines
12 KiB
Python
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
|