Backup automatico script del 2026-09-27 07:00
This commit is contained in:
1 parent
f347badf3b
commit
09dcbbdadb
24 files changed
+1164
-193
No files matched your search
@@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -13,6 +14,26 @@ from ..knowledge import gitea
|
||||
|
||||
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
# Contesti condivisi: stessa struttura dei progetti personali, sotto un namespace comune.
|
||||
# Tutta la famiglia li legge e li aggiorna; archiviazione e ritorno a personale solo
|
||||
# per proprietario o admin.
|
||||
SHARED_NS = "_shared"
|
||||
SHARED_WITH_FAMILY = "family"
|
||||
_SHARED_META_KEYS = ("shared", "shared_with", "owner", "shared_at", "shared_by", "updated_by")
|
||||
|
||||
|
||||
class ConflictError(ValueError):
|
||||
"""Il contesto è stato modificato da qualcun altro dopo l'ultima lettura."""
|
||||
|
||||
# I tool MCP girano in parallelo nel threadpool: serializza le scritture per progetto
|
||||
_project_locks: dict[tuple[str, str], threading.Lock] = {}
|
||||
_project_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
def _project_lock(username: str, project_id: str) -> threading.Lock:
|
||||
with _project_locks_guard:
|
||||
return _project_locks.setdefault((username, project_id), threading.Lock())
|
||||
|
||||
|
||||
def _context_root() -> str:
|
||||
return os.environ.get("MCP_CONTEXT_ROOT", "/data/context")
|
||||
@@ -24,19 +45,60 @@ def _user_root(username: str) -> str:
|
||||
return path
|
||||
|
||||
|
||||
def _shared_root() -> str:
|
||||
path = os.path.join(_context_root(), SHARED_NS, "projects")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _project_dir(username: str, project_id: str) -> str:
|
||||
if not SLUG_RE.match(project_id):
|
||||
raise ValueError("ID progetto non valido")
|
||||
return os.path.join(_user_root(username), project_id)
|
||||
|
||||
|
||||
def _id_taken(project_id: str, username: str, shared: bool) -> bool:
|
||||
"""Un ID condiviso non deve coincidere con progetti personali di nessun utente
|
||||
(altrimenti per quell'utente il personale nasconderebbe il condiviso)."""
|
||||
root = _context_root()
|
||||
if os.path.exists(os.path.join(_shared_root(), project_id)):
|
||||
return True
|
||||
users = [u for u in os.listdir(root) if u != SHARED_NS] if shared else [username]
|
||||
return any(os.path.exists(os.path.join(root, u, "projects", project_id)) for u in users)
|
||||
|
||||
|
||||
def _read_meta(meta_path: str) -> dict:
|
||||
with open(meta_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _is_member(meta: dict) -> bool:
|
||||
return meta.get("shared_with") == SHARED_WITH_FAMILY
|
||||
|
||||
|
||||
def _resolve_project(username: str, project_id: str) -> tuple[str, str]:
|
||||
"""(namespace, cartella) del progetto: prima quello personale, poi quello condiviso."""
|
||||
personal = _project_dir(username, project_id)
|
||||
if os.path.isfile(os.path.join(personal, "meta.json")):
|
||||
return username, personal
|
||||
shared = os.path.join(_shared_root(), project_id)
|
||||
meta_path = os.path.join(shared, "meta.json")
|
||||
if os.path.isfile(meta_path) and _is_member(_read_meta(meta_path)):
|
||||
return SHARED_NS, shared
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
|
||||
|
||||
def _load_meta(username: str, project_id: str) -> tuple[str, dict]:
|
||||
proj_dir = _project_dir(username, project_id)
|
||||
_, proj_dir = _resolve_project(username, project_id)
|
||||
meta_path = os.path.join(proj_dir, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||
return meta_path, meta
|
||||
return meta_path, _read_meta(meta_path)
|
||||
|
||||
|
||||
def _require_owner_or_admin(username: str, meta: dict, is_admin: bool, action: str) -> None:
|
||||
if meta.get("shared") and not is_admin and meta.get("owner") != username:
|
||||
raise PermissionError(
|
||||
f"Solo il proprietario ({meta.get('owner')}) o un admin può {action} un contesto condiviso"
|
||||
)
|
||||
|
||||
|
||||
def _save_meta(meta_path: str, meta: dict) -> None:
|
||||
@@ -67,7 +129,7 @@ def _apply_gitea_link(
|
||||
meta.pop("gitea_repo", None)
|
||||
|
||||
if seed_from_gitea and repo:
|
||||
proj_dir = _project_dir(username, meta["id"])
|
||||
_, proj_dir = _resolve_project(username, meta["id"])
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||
new_md, changed = gitea_link.seed_context_from_readme(username, repo, context_md)
|
||||
@@ -80,18 +142,19 @@ def _apply_gitea_link(
|
||||
|
||||
|
||||
def list_projects(username: str, include_archived: bool = False) -> list:
|
||||
root = _user_root(username)
|
||||
"""Progetti personali dell'utente seguiti dai contesti condivisi (campo shared=true)."""
|
||||
projects = []
|
||||
if not os.path.isdir(root):
|
||||
return projects
|
||||
for name in sorted(os.listdir(root)):
|
||||
meta_path = os.path.join(root, name, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
continue
|
||||
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||
if meta.get("archived") and not include_archived:
|
||||
continue
|
||||
projects.append(meta)
|
||||
for root, shared in ((_user_root(username), False), (_shared_root(), True)):
|
||||
for name in sorted(os.listdir(root)):
|
||||
meta_path = os.path.join(root, name, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
continue
|
||||
meta = _read_meta(meta_path)
|
||||
if shared and not _is_member(meta):
|
||||
continue
|
||||
if meta.get("archived") and not include_archived:
|
||||
continue
|
||||
projects.append(meta)
|
||||
return projects
|
||||
|
||||
|
||||
@@ -101,13 +164,14 @@ def create_project(
|
||||
tags: Optional[list[str]] = None,
|
||||
gitea_repo: Optional[str] = None,
|
||||
seed_from_gitea: bool = False,
|
||||
shared: bool = False,
|
||||
) -> dict:
|
||||
slug_base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "progetto"
|
||||
project_id = slug_base[:40]
|
||||
root = _user_root(username)
|
||||
while os.path.exists(os.path.join(root, project_id)):
|
||||
project_id = f"{slug_base[:32]}-{secrets.token_hex(2)}"
|
||||
proj_dir = _project_dir(username, project_id)
|
||||
project_id = slug_base[:40].strip("-")
|
||||
# ID univoco sia tra i progetti personali sia tra i condivisi (la risoluzione li unisce)
|
||||
while _id_taken(project_id, username, shared):
|
||||
project_id = f"{slug_base[:32].strip('-')}-{secrets.token_hex(2)}"
|
||||
proj_dir = os.path.join(_shared_root() if shared else _user_root(username), project_id)
|
||||
os.makedirs(os.path.join(proj_dir, "sessions"), exist_ok=True)
|
||||
os.makedirs(os.path.join(proj_dir, "artifacts"), exist_ok=True)
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
@@ -120,6 +184,8 @@ def create_project(
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if shared:
|
||||
meta.update(_shared_fields(username, now))
|
||||
repo = gitea_link.normalize_gitea_repo(gitea_repo)
|
||||
if repo:
|
||||
meta["gitea_repo"] = repo
|
||||
@@ -142,21 +208,73 @@ def create_project(
|
||||
return meta
|
||||
|
||||
|
||||
def _shared_fields(username: str, now: str) -> dict:
|
||||
return {
|
||||
"shared": True,
|
||||
"shared_with": SHARED_WITH_FAMILY,
|
||||
"owner": username,
|
||||
"shared_by": username,
|
||||
"shared_at": now,
|
||||
}
|
||||
|
||||
|
||||
def share_project(username: str, project_id: str, shared: bool = True, is_admin: bool = False) -> dict:
|
||||
"""Sposta un progetto tra lo spazio personale del proprietario e quello condiviso.
|
||||
|
||||
La storia (context.md, sessions/, artifacts/) si sposta con il progetto.
|
||||
"""
|
||||
ns, proj_dir = _resolve_project(username, project_id)
|
||||
meta = _read_meta(os.path.join(proj_dir, "meta.json"))
|
||||
if shared:
|
||||
if ns == SHARED_NS:
|
||||
return meta
|
||||
target = os.path.join(_shared_root(), project_id)
|
||||
others = [
|
||||
u for u in os.listdir(_context_root())
|
||||
if u not in (SHARED_NS, username) and os.path.exists(os.path.join(_context_root(), u, "projects", project_id))
|
||||
]
|
||||
if others:
|
||||
raise ValueError(
|
||||
f"L'ID '{project_id}' è usato anche da un progetto personale di {', '.join(others)}: "
|
||||
"crea un contesto condiviso nuovo (create_project shared=true) e copiaci il contenuto"
|
||||
)
|
||||
else:
|
||||
if ns != SHARED_NS:
|
||||
return meta
|
||||
_require_owner_or_admin(username, meta, is_admin, "rendere personale")
|
||||
target = os.path.join(_user_root(meta["owner"]), project_id)
|
||||
if os.path.exists(target):
|
||||
raise ValueError(f"Esiste già un progetto '{project_id}' nella destinazione: rinomina prima")
|
||||
with _project_lock(ns, project_id):
|
||||
os.rename(proj_dir, target)
|
||||
meta_path = os.path.join(target, "meta.json")
|
||||
meta = _read_meta(meta_path)
|
||||
if shared:
|
||||
meta.update(_shared_fields(username, datetime.now().astimezone().isoformat(timespec="seconds")))
|
||||
else:
|
||||
for key in _SHARED_META_KEYS:
|
||||
meta.pop(key, None)
|
||||
_save_meta(meta_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def link_project_repo(
|
||||
username: str,
|
||||
project_id: str,
|
||||
gitea_repo: Optional[str] = None,
|
||||
seed_from_gitea: bool = False,
|
||||
) -> dict:
|
||||
meta_path, meta = _load_meta(username, project_id)
|
||||
meta = _apply_gitea_link(
|
||||
username,
|
||||
meta,
|
||||
gitea_repo,
|
||||
verify=bool(gitea_link.normalize_gitea_repo(gitea_repo)),
|
||||
seed_from_gitea=seed_from_gitea,
|
||||
)
|
||||
_save_meta(meta_path, meta)
|
||||
ns, _ = _resolve_project(username, project_id)
|
||||
with _project_lock(ns, project_id):
|
||||
meta_path, meta = _load_meta(username, project_id)
|
||||
meta = _apply_gitea_link(
|
||||
username,
|
||||
meta,
|
||||
gitea_repo,
|
||||
verify=bool(gitea_link.normalize_gitea_repo(gitea_repo)),
|
||||
seed_from_gitea=seed_from_gitea,
|
||||
)
|
||||
_save_meta(meta_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
@@ -166,11 +284,8 @@ def get_project_context(
|
||||
session_limit: int = 5,
|
||||
include_gitea: bool = True,
|
||||
) -> dict:
|
||||
proj_dir = _project_dir(username, project_id)
|
||||
meta_path = os.path.join(proj_dir, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||
_, proj_dir = _resolve_project(username, project_id)
|
||||
meta = _read_meta(os.path.join(proj_dir, "meta.json"))
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else ""
|
||||
sessions_dir = os.path.join(proj_dir, "sessions")
|
||||
@@ -189,11 +304,33 @@ def get_project_context(
|
||||
return result
|
||||
|
||||
|
||||
def save_context(username: str, project_id: str, content: str, mode: str = "append") -> dict:
|
||||
proj_dir = _project_dir(username, project_id)
|
||||
def save_context(
|
||||
username: str,
|
||||
project_id: str,
|
||||
content: str,
|
||||
mode: str = "append",
|
||||
if_revision: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""if_revision: meta.revision letto prima di modificare; se nel frattempo qualcuno
|
||||
ha salvato, ConflictError invece di sovrascrivere (utile nei contesti condivisi)."""
|
||||
ns, proj_dir = _resolve_project(username, project_id)
|
||||
with _project_lock(ns, project_id):
|
||||
return _save_context_locked(username, proj_dir, content, mode, if_revision)
|
||||
|
||||
|
||||
def _save_context_locked(
|
||||
username: str, proj_dir: str, content: str, mode: str, if_revision: Optional[int]
|
||||
) -> dict:
|
||||
meta_path = os.path.join(proj_dir, "meta.json")
|
||||
if not os.path.isfile(meta_path):
|
||||
raise FileNotFoundError("Progetto non trovato")
|
||||
meta = _read_meta(meta_path)
|
||||
if if_revision is not None and int(meta.get("revision", 0)) != int(if_revision):
|
||||
raise ConflictError(
|
||||
f"Contesto modificato (revisione {meta.get('revision', 0)}) il {meta.get('updated_at')}"
|
||||
+ (f" da {meta['updated_by']}" if meta.get("updated_by") else "")
|
||||
+ ": rileggilo con get_project_context e riapplica la modifica"
|
||||
)
|
||||
ctx_path = os.path.join(proj_dir, "context.md")
|
||||
if mode == "replace":
|
||||
text = content
|
||||
@@ -202,31 +339,45 @@ def save_context(username: str, project_id: str, content: str, mode: str = "appe
|
||||
text = existing.rstrip() + "\n\n" + content.strip() + "\n"
|
||||
with open(ctx_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
meta = json.load(open(meta_path, encoding="utf-8"))
|
||||
meta["revision"] = int(meta.get("revision", 0)) + 1
|
||||
if meta.get("shared"):
|
||||
meta["updated_by"] = username
|
||||
_save_meta(meta_path, meta)
|
||||
snapshot = datetime.now().strftime("%Y%m%d-%H%M%S") + ".md"
|
||||
# Nei condivisi il nome della sessione dice chi ha scritto
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + (f"-{username}" if meta.get("shared") else "")
|
||||
snapshot = stamp + ".md"
|
||||
n = 1
|
||||
while os.path.exists(os.path.join(proj_dir, "sessions", snapshot)):
|
||||
snapshot = f"{stamp}-{n}.md"
|
||||
n += 1
|
||||
with open(os.path.join(proj_dir, "sessions", snapshot), "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return meta
|
||||
|
||||
|
||||
def archive_project(username: str, project_id: str, archived: bool = True) -> dict:
|
||||
meta_path, meta = _load_meta(username, project_id)
|
||||
meta["archived"] = archived
|
||||
meta["status"] = "archived" if archived else "active"
|
||||
_save_meta(meta_path, meta)
|
||||
def archive_project(
|
||||
username: str, project_id: str, archived: bool = True, is_admin: bool = False
|
||||
) -> dict:
|
||||
ns, _ = _resolve_project(username, project_id)
|
||||
with _project_lock(ns, project_id):
|
||||
meta_path, meta = _load_meta(username, project_id)
|
||||
_require_owner_or_admin(username, meta, is_admin, "archiviare")
|
||||
meta["archived"] = archived
|
||||
meta["status"] = "archived" if archived else "active"
|
||||
_save_meta(meta_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def list_resources(username: str) -> list:
|
||||
resources = []
|
||||
for meta in list_projects(username, include_archived=False):
|
||||
desc = f"Contesto progetto {meta['id']}"
|
||||
ns = SHARED_NS if meta.get("shared") else username
|
||||
desc = f"Contesto {'condiviso con la famiglia' if meta.get('shared') else 'progetto'} {meta['id']}"
|
||||
if meta.get("gitea_repo"):
|
||||
desc += f" (Gitea: {meta['gitea_repo']})"
|
||||
resources.append(
|
||||
{
|
||||
"uri": f"loogle://context/{username}/{meta['id']}",
|
||||
"uri": f"loogle://context/{ns}/{meta['id']}",
|
||||
"name": meta["title"],
|
||||
"description": desc,
|
||||
"mimeType": "text/markdown",
|
||||
@@ -236,10 +387,13 @@ def list_resources(username: str) -> list:
|
||||
|
||||
|
||||
def read_resource(username: str, uri: str) -> dict:
|
||||
prefix = f"loogle://context/{username}/"
|
||||
if not uri.startswith(prefix):
|
||||
for ns in (username, SHARED_NS):
|
||||
prefix = f"loogle://context/{ns}/"
|
||||
if uri.startswith(prefix):
|
||||
project_id = uri[len(prefix):]
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError("Risorsa non trovata")
|
||||
project_id = uri[len(prefix):]
|
||||
data = get_project_context(username, project_id, session_limit=0, include_gitea=False)
|
||||
text = data["context_md"]
|
||||
gitea_repo = data["meta"].get("gitea_repo")
|
||||
|
||||