Files
roboco/tests/unit/services/test_vault_seams.py
T
d03181ab48 feat(vault): Obsidian vault V2 — janitor, archival, weekly report, KB ingest, Bases + sync runbook (#482)
* feat(vault): V2 — create-seam + drift janitor, archival, weekly org-report, KB ingest, Bases views + sync runbook

Implements the vault V2 canonical spec end to end (the splice guard shipped
separately and is reused at KB-ingest time):

- materialize-on-create: TaskService.create writes each task's note best-effort
  from the moment it exists; the transition-touch stops no-oping on live work
- drift janitor (services/vault_janitor.py + hourly _vault_janitor_loop): daily
  changed-task re-projection, random drift sample, archival pass — restart-proof
  via RoboCo/_meta/.janitor_state.json, 200/cycle caps, per-item isolation,
  processed-only resume markers, self-repairing state file
- archival: vault_archive_days (30, 0=off) moves old terminal tasks' notes to
  RoboCo/Archive/<year>/Tasks/<project>/ — one write_task code path for janitor
  and rebuild, id8 lookup across Tasks/+Archive/, alias links keep moves safe
- weekly org-report: VaultWriter.write_org_report renders Reports/<ISO-week>.md
  from MetricsService/UsageService (numbers duplicated into frontmatter for
  trend queries), once per ISO week, with a best-effort CEO notification
- KB ingest: IndexType.VAULT_NOTES + VaultNotesIndexPlugin + _vault_kb_loop
  embed the CEO's RoboCo/Notes into the RAG corpus — injection guard as a hard
  gate (flagged notes quarantined with an idempotent callout), traversal- and
  symlink-contained at both config and engine layers, content-hash dedup,
  50-ingest/cycle cap, frontmatter stripped; reaches roboco_kb_search, the
  mentor default domain, claim-time briefings (kind vault_note), and the panel
  KB browser; no migration (chunks table auto-creates; migration 030's
  CHUNK_TABLES tuple appended per the chunks_playbooks precedent)
- Bases views (Task Board.base, Reports.base — schema verified against the
  Obsidian docs) + the Mac sync runbook vault asset
- config/flags/compose: vault_archive_days, vault_report_enabled (flags card),
  vault_kb_enabled (flags card; NAS compose arms it, registry ships it off),
  vault_kb_dirs (+ overlap/traversal validator), vault_kb_interval_seconds
- e2e smoke (tests/e2e_smoke/test_vault_v2.py): real create-seam, real janitor
  cycle incl. archival + state, real KB engine + real guard

* docs: vault V2 sweep — map, RAG corpus, CLAUDE.md

- docs/map/vault.md: V1+V2 — janitor/archival/report/KB data flows, new files,
  config, health posture
- docs/map/orchestrator.md + task-service.md: the two new loops, the create
  seam, the three janitor queries
- docs/rag/architecture/obsidian-vault.md: agent-facing what-changed (notes
  from creation, archive link-safety, CEO notes retrievable, weekly report)
- docs/rag/architecture/config-reference.md: the five new settings
- CLAUDE.md: vault paragraph covers V1+V2; flags-card list mentions the vault
  report/KB flags

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-11 15:51:19 +02:00

275 lines
9.4 KiB
Python

"""Vault event seams (journal write / A2A send / task status transition):
best-effort isolation. A raised VaultWriter error must NEVER fail the
underlying verb; the flag off must short-circuit before any writer call;
``is_private`` journal entries must be excluded (same rule as the RAG corpus).
"""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.models.base import JournalEntryType
from roboco.services.a2a import A2AService
from roboco.services.journal import JournalService
from roboco.services.task import TaskService
from roboco.services.vault_writer import VaultWriter
if TYPE_CHECKING:
from pathlib import Path
from roboco.db.tables import TaskTable
def _entry_row(*, is_private: bool = False, task_id: object | None = None) -> MagicMock:
row = MagicMock()
row.id = uuid4()
row.task_id = task_id
row.title = "Some entry"
row.content = "Body text"
row.timestamp = datetime.now(UTC)
row.type = JournalEntryType.GENERAL
row.is_private = is_private
return row
# --- journal seam --------------------------------------------------------- #
@pytest.mark.asyncio
async def test_journal_seam_noop_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
svc = JournalService(MagicMock())
monkeypatch.setattr(svc, "get_agent_slug", AsyncMock(return_value="be-dev-1"))
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
await svc._materialize_vault_note(_entry_row(), uuid4(), is_private=False)
get_writer.assert_not_called()
@pytest.mark.asyncio
async def test_journal_seam_excludes_private_entries(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Mirrors the RAG-index exclusion: a private entry never reaches the vault."""
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = JournalService(MagicMock())
monkeypatch.setattr(svc, "get_agent_slug", AsyncMock(return_value="be-dev-1"))
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
await svc._materialize_vault_note(
_entry_row(is_private=True), uuid4(), is_private=True
)
get_writer.assert_not_called()
@pytest.mark.asyncio
async def test_journal_seam_writer_failure_does_not_raise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = JournalService(MagicMock())
monkeypatch.setattr(svc, "get_agent_slug", AsyncMock(return_value="be-dev-1"))
writer = MagicMock()
writer.write_journal_entry.side_effect = OSError("disk full")
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
await svc._materialize_vault_note(_entry_row(), uuid4(), is_private=False)
writer.write_journal_entry.assert_called_once()
@pytest.mark.asyncio
async def test_journal_seam_writes_when_enabled_and_public(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = JournalService(MagicMock())
monkeypatch.setattr(svc, "get_agent_slug", AsyncMock(return_value="be-dev-1"))
writer = MagicMock()
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
await svc._materialize_vault_note(_entry_row(), uuid4(), is_private=False)
writer.write_journal_entry.assert_called_once()
# --- A2A seam --------------------------------------------------------------- #
def _a2a_msg() -> MagicMock:
msg = MagicMock()
msg.id = uuid4()
msg.content = "hello"
msg.created_at = datetime.now(UTC)
return msg
def _a2a_conv(task_id: object | None = None) -> MagicMock:
conv = MagicMock()
conv.id = uuid4()
conv.task_id = task_id
return conv
@pytest.mark.asyncio
async def test_a2a_seam_noop_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
await A2AService._materialize_vault_note(
_a2a_msg(), _a2a_conv(), "be-dev-1", "be-pm"
)
get_writer.assert_not_called()
@pytest.mark.asyncio
async def test_a2a_seam_writer_failure_does_not_raise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
writer = MagicMock()
writer.append_a2a_message.side_effect = RuntimeError("boom")
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
await A2AService._materialize_vault_note(
_a2a_msg(), _a2a_conv(), "be-dev-1", "be-pm"
)
writer.append_a2a_message.assert_called_once()
# --- task status-transition seam -------------------------------------------- #
def _task_row() -> MagicMock:
task = MagicMock()
task.id = uuid4()
task.pr_number = None
task.pr_url = None
return task
def test_task_transition_seam_noop_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
svc = TaskService.__new__(TaskService)
svc.log = MagicMock()
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
svc._touch_vault_frontmatter(
_task_row(), to_status="in_progress", team="backend"
)
get_writer.assert_not_called()
def test_task_transition_seam_writer_failure_does_not_raise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = TaskService.__new__(TaskService)
svc.log = MagicMock()
writer = MagicMock()
writer.touch_task_frontmatter.side_effect = OSError("nope")
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
svc._touch_vault_frontmatter(
_task_row(), to_status="in_progress", team="backend"
)
writer.touch_task_frontmatter.assert_called_once()
def test_task_transition_seam_touches_status_team_pr(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = TaskService.__new__(TaskService)
svc.log = MagicMock()
task = _task_row()
task.pr_number = 7
task.pr_url = "https://github.com/x/y/pull/7"
writer = MagicMock()
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
svc._touch_vault_frontmatter(task, to_status="awaiting_qa", team="backend")
writer.touch_task_frontmatter.assert_called_once_with(
task_id=str(task.id),
status="awaiting_qa",
team="backend",
pr_number=7,
pr_url="https://github.com/x/y/pull/7",
)
# --- materialize-on-create seam ---------------------------------------------- #
def _fresh_task_stub() -> TaskTable:
stub = SimpleNamespace(
id=uuid4(),
title="Fresh task",
description="Just created.",
status="pending",
team="backend",
priority=2,
task_type="code",
acceptance_criteria=[],
pr_number=None,
pr_url=None,
project_id=None,
parent_task_id=None,
dependency_ids=None,
batch_id=None,
completed_at=None,
updated_at=None,
created_at=datetime.now(UTC),
)
return cast("TaskTable", stub)
def _create_seam_service() -> TaskService:
svc = TaskService.__new__(TaskService)
svc.log = MagicMock()
svc.session = MagicMock()
object.__setattr__(svc, "get", AsyncMock(return_value=None))
object.__setattr__(svc, "get_subtasks", AsyncMock(return_value=[]))
return svc
@pytest.mark.asyncio
async def test_create_seam_noop_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
svc = _create_seam_service()
with patch("roboco.services.vault_writer.get_vault_writer") as get_writer:
await svc._materialize_vault_note(_fresh_task_stub())
get_writer.assert_not_called()
@pytest.mark.asyncio
async def test_create_seam_writer_failure_does_not_raise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = _create_seam_service()
writer = MagicMock()
writer.write_task.side_effect = OSError("disk full")
with patch("roboco.services.vault_writer.get_vault_writer", return_value=writer):
await svc._materialize_vault_note(_fresh_task_stub())
writer.write_task.assert_called_once()
@pytest.mark.asyncio
async def test_create_seam_materializes_note_in_tmp_vault(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
svc = _create_seam_service()
task = _fresh_task_stub()
with patch(
"roboco.services.vault_writer.get_vault_writer",
return_value=VaultWriter(tmp_path),
):
await svc._materialize_vault_note(task)
note = VaultWriter(tmp_path).find_task_note(str(task.id))
assert note is not None
text = note.read_text(encoding="utf-8")
assert "status: pending" in text
# Narrative stays Auditor-owned: only the placeholder is rendered.
assert "_Pending Auditor curation._" in text