feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:06:40 -04:00
parent c55e3e0cbc
commit 4c4e415975
27 changed files with 1465 additions and 386 deletions
+29
View File
@@ -0,0 +1,29 @@
"""Domain errors shared by the reusable service layer.
Services are transport-agnostic: they raise :class:`ServiceError` carrying a
stable ``code`` and an HTTP ``status`` hint. The REST layer maps it to an
``HTTPException`` (via the global handler in ``backend.main``) while the tool
layer maps it to a :class:`backend.tools.context.ToolError`.
"""
from __future__ import annotations
from typing import Any
class ServiceError(Exception):
"""Base error for the shared business-logic services."""
def __init__(
self,
message: str,
*,
code: str = "service_error",
status: int = 400,
details: dict[str, Any] | None = None,
):
super().__init__(message)
self.message = message
self.code = code
self.status = status
self.details = details or {}
+86
View File
@@ -0,0 +1,86 @@
"""File reading services shared by REST routes and the AI tool layer."""
from __future__ import annotations
import logging
from typing import Any
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
logger = logging.getLogger("obsigate.services.files")
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
"""Return the raw text content of a vault file (no redaction)."""
root = get_vault_root(vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
logger.error(f"Permission denied reading raw file {path}: {e}")
raise ServiceError(f"Permission denied: cannot read file {path}", code="permission_denied", status=403) from e
except UnicodeDecodeError:
try:
raw = file_path.read_bytes().decode("utf-8", errors="replace")
except Exception as e:
logger.error(f"Error reading binary raw file {path}: {e}")
raise ServiceError(f"Cannot read file: {e!s}", code="read_error", status=500) from e
except Exception as e:
logger.error(f"Unexpected error reading raw file {path}: {e}")
raise ServiceError(f"Error reading file: {e!s}", code="read_error", status=500) from e
return {"vault": vault_name, "path": path, "raw": raw}
def read_file_text(
vault_name: str,
path: str,
*,
redact: bool = True,
max_bytes: int | None = None,
) -> dict[str, Any]:
"""Return a vault file's text content, optionally redacted and size-capped.
Raises:
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
error (500).
"""
root = get_vault_root(vault_name)
target = resolve_safe_path(root, path)
if not target.exists() or not target.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
size = target.stat().st_size
if max_bytes is not None and size > max_bytes:
raise ServiceError(
f"File too large ({size} bytes > {max_bytes})",
code="file_too_large",
status=413,
details={"vault": vault_name, "path": path, "size": size},
)
content = target.read_text(encoding="utf-8", errors="replace")
if redact:
from backend.secret_redactor import redact_file_content
content = redact_file_content(content, path)
return {"vault": vault_name, "path": path, "size": size, "content": content}
+45
View File
@@ -0,0 +1,45 @@
"""Vault path resolution shared by the REST routes and the AI tool layer.
This is the single implementation of the anti path-traversal check. Routes map
:class:`ServiceError` to ``HTTPException`` and tools map it to
:class:`backend.tools.context.ToolError`.
"""
from __future__ import annotations
import logging
from pathlib import Path
from backend.services.errors import ServiceError
logger = logging.getLogger("obsigate.services.paths")
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a vault-relative path, rejecting traversal outside the vault.
Raises:
ServiceError: ``path_error`` (500) when the path cannot be resolved,
``path_outside_vault`` (403) when it escapes the vault root.
"""
full_path = vault_root / (relative_path or "")
try:
resolved = full_path.resolve(strict=False)
root = vault_root.resolve(strict=False)
except Exception as e:
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e
try:
resolved.relative_to(root)
except ValueError:
# Case-insensitive fallback for Windows / Docker path casing.
if not str(resolved).lower().startswith(str(root).lower()):
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
raise ServiceError(
"Access denied: path outside vault",
code="path_outside_vault",
status=403,
details={"path": relative_path},
) from None
return resolved
+41
View File
@@ -0,0 +1,41 @@
"""Search services shared by REST routes and the AI tool layer."""
from __future__ import annotations
from typing import Any
def search_vaults(
q: str,
vault: str = "all",
tag: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
"""Full-text search with pagination, returned as the API response payload.
No permission filtering is applied here: callers that need it (the tool
layer) filter the ``results`` list themselves.
"""
from backend.search import search
all_results = search(q, vault_filter=vault, tag_filter=tag)
total = len(all_results)
page = all_results[offset: offset + limit]
return {
"query": q,
"vault_filter": vault,
"tag_filter": tag,
"count": len(page),
"total": total,
"offset": offset,
"limit": limit,
"results": page,
}
def list_tags(vault: str | None = None) -> dict[str, int]:
"""Return tag → count, optionally restricted to a single vault."""
from backend.search import get_all_tags
return get_all_tags(vault_filter=vault)
+102
View File
@@ -0,0 +1,102 @@
"""Vault listing and directory browsing services.
Single source of truth consumed by both the REST routes (``/api/vaults``,
``/api/browse/{vault}``) and the AI tool layer (``list_vaults``,
``list_directory``).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
"""Return the vaults *user* may access, with summary metadata."""
from backend.auth.middleware import check_vault_access
from backend.indexer import index
result: list[dict[str, Any]] = []
for name, data in index.items():
if not check_vault_access(name, user):
continue
result.append({
"name": name,
"file_count": len(data.get("files", [])),
"tag_count": len(data.get("tags", {})),
"type": data.get("config", {}).get("type", "VAULT"),
})
return result
def get_vault_root(vault_name: str) -> Path:
"""Return the filesystem root of *vault_name* or raise ``not_found``."""
from backend.indexer import get_vault_data
data = get_vault_data(vault_name)
if not data:
raise ServiceError(
f"Vault '{vault_name}' not found",
code="not_found",
status=404,
details={"vault": vault_name},
)
return Path(data["path"])
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"""Return the direct children of a vault directory (directories first)."""
from backend.indexer import SUPPORTED_EXTENSIONS
from backend.vault_settings import get_vault_setting
root = get_vault_root(vault_name)
target = resolve_safe_path(root, path) if path else root.resolve()
if not target.exists():
raise ServiceError(
f"Path not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
hide_hidden = (get_vault_setting(vault_name) or {}).get("hideHiddenFiles", False)
items: list[dict[str, Any]] = []
try:
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if hide_hidden and entry.name.startswith("."):
continue
rel = str(entry.relative_to(root)).replace("\\", "/")
if entry.is_dir():
# Count only direct children (files and subdirs) for performance.
try:
file_count = sum(
1 for child in entry.iterdir()
if (not hide_hidden or not child.name.startswith("."))
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
or child.is_dir())
)
except PermissionError:
file_count = 0
items.append({
"name": entry.name,
"path": rel,
"type": "directory",
"children_count": file_count,
})
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
items.append({
"name": entry.name,
"path": rel,
"type": "file",
"size": entry.stat().st_size,
"extension": entry.suffix.lower(),
})
except PermissionError:
raise ServiceError("Permission denied", code="permission_denied", status=403) from None
return {"vault": vault_name, "path": path, "items": items}