Files
roboco/tests/unit/services/test_vault_janitor.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

504 lines
18 KiB
Python

"""VaultJanitor — state-file due-logic, drift repair, archival, weekly report.
No DB: task/project services are stubbed; the writer runs against a real
tmp_path vault (the vault_writer test style).
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.services import vault_assembly
from roboco.services.vault_assembly import assemble_task_note_data
from roboco.services.vault_janitor import VaultJanitor, _iso_week
from roboco.services.vault_writer import TaskNoteData, VaultWriter
_TASK_ID = "11112222-3333-4444-5555-666677778888"
_TEST_CAP = 2
def _task_stub(**overrides: Any) -> SimpleNamespace:
base: dict[str, Any] = {
"id": _TASK_ID,
"title": "Add login endpoint",
"description": "Implement it.",
"status": "in_progress",
"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),
}
base.update(overrides)
return SimpleNamespace(**base)
def _touched(task: Any) -> datetime:
return cast("datetime", task.updated_at or task.created_at)
class _TaskSvcStub:
"""Duck-typed TaskService mirroring the real queries' filter + ascending
order, so the janitor's capped resume-marker logic is exercised for real."""
def __init__(
self,
changed: list[Any] | None = None,
sample: list[Any] | None = None,
archive: list[Any] | None = None,
) -> None:
self.changed = changed or []
self.sample = sample or []
self.archive = archive or []
self.calls: list[str] = []
async def list_updated_since(
self, since: datetime, limit: int = 100, offset: int = 0
) -> list[Any]:
assert since.tzinfo is not None
self.calls.append("list_updated_since")
eligible = sorted(
(t for t in self.changed if _touched(t) >= since), key=_touched
)
return eligible[offset : offset + limit]
async def sample_stale_tasks(self, before: datetime, limit: int = 20) -> list[Any]:
assert before.tzinfo is not None
self.calls.append("sample_stale_tasks")
return self.sample[:limit]
async def list_archive_candidates(
self, after: datetime, before: datetime, limit: int = 100, offset: int = 0
) -> list[Any]:
assert after < before
self.calls.append("list_archive_candidates")
eligible = sorted(
(
t
for t in self.archive
if after <= (t.completed_at or _touched(t)) < before
),
key=lambda t: t.completed_at or _touched(t),
)
return eligible[offset : offset + limit]
async def get(self, task_id: Any) -> Any:
assert task_id is not None
return None
async def get_subtasks(self, task_id: Any) -> list[Any]:
assert task_id is not None
return []
def _janitor(
monkeypatch: pytest.MonkeyPatch, vault: Any, task_svc: _TaskSvcStub
) -> VaultJanitor:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(vault))
monkeypatch.setattr(
"roboco.services.vault_janitor.get_task_service", lambda _s: task_svc
)
monkeypatch.setattr(
"roboco.services.vault_janitor.get_project_service", lambda _s: MagicMock()
)
return VaultJanitor(MagicMock())
def _state_file(vault: Any) -> Any:
return vault / "RoboCo" / "_meta" / ".janitor_state.json"
def _write_state(vault: Any, state: dict[str, Any]) -> None:
path = _state_file(vault)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state), encoding="utf-8")
def _note_data(**overrides: Any) -> TaskNoteData:
base: dict[str, Any] = {
"id": _TASK_ID,
"title": "Add login endpoint",
"project_slug": "unassigned",
"description": "Implement it.",
"status": "in_progress",
"team": "backend",
"priority": 2,
"task_type": "code",
}
base.update(overrides)
return TaskNoteData(**base)
# --- gating / due-logic ---------------------------------------------------- #
@pytest.mark.asyncio
async def test_run_cycle_noop_when_vault_flag_off(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
monkeypatch.setattr(settings, "vault_path", str(tmp_path))
result = await VaultJanitor(MagicMock()).run_cycle()
assert result == {}
assert not _state_file(tmp_path).exists()
@pytest.mark.asyncio
async def test_fresh_vault_sweep_is_due_and_state_persists(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
task_svc = _TaskSvcStub()
janitor = _janitor(monkeypatch, tmp_path, task_svc)
await janitor.run_cycle()
assert "list_updated_since" in task_svc.calls
state = json.loads(_state_file(tmp_path).read_text(encoding="utf-8"))
assert "last_sweep" in state
@pytest.mark.asyncio
async def test_recent_sweep_not_due(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
_write_state(tmp_path, {"last_sweep": datetime.now(UTC).isoformat()})
task_svc = _TaskSvcStub()
janitor = _janitor(monkeypatch, tmp_path, task_svc)
result = await janitor.run_cycle()
assert task_svc.calls == []
assert result == {"repaired": 0, "archived": 0, "failed": 0}
@pytest.mark.asyncio
async def test_corrupt_state_value_degrades_to_no_state(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
"""Valid JSON with a non-string value must not wedge the janitor: the
sweep runs as if unswept and the state file is repaired in place."""
monkeypatch.setattr(settings, "vault_report_enabled", False)
_write_state(tmp_path, {"last_sweep": 12345, "archive_watermark": True})
task_svc = _TaskSvcStub()
janitor = _janitor(monkeypatch, tmp_path, task_svc)
result = await janitor.run_cycle()
assert result == {"repaired": 0, "archived": 0, "failed": 0}
assert "list_updated_since" in task_svc.calls
state = json.loads(_state_file(tmp_path).read_text(encoding="utf-8"))
assert datetime.fromisoformat(state["last_sweep"]).tzinfo is not None
# --- drift repair ----------------------------------------------------------- #
@pytest.mark.asyncio
async def test_changed_task_reprojection_preserves_narrative(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
writer = VaultWriter(tmp_path)
writer.write_task(_note_data(narrative="Auditor prose survives."))
task_svc = _TaskSvcStub(changed=[_task_stub(status="awaiting_qa")])
janitor = _janitor(monkeypatch, tmp_path, task_svc)
result = await janitor.run_cycle()
assert result["repaired"] == 1
note = writer.find_task_note(_TASK_ID)
assert note is not None
text = note.read_text(encoding="utf-8")
assert "Auditor prose survives." in text
assert "status: awaiting_qa" in text
@pytest.mark.asyncio
async def test_sample_verification_recreates_deleted_note(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
task_svc = _TaskSvcStub(sample=[_task_stub()])
janitor = _janitor(monkeypatch, tmp_path, task_svc)
result = await janitor.run_cycle()
assert result["repaired"] == 1
assert VaultWriter(tmp_path).find_task_note(_TASK_ID) is not None
@pytest.mark.asyncio
async def test_sample_verification_touches_stale_status(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
writer = VaultWriter(tmp_path)
writer.write_task(_note_data(status="pending"))
task_svc = _TaskSvcStub(sample=[_task_stub(status="in_progress")])
janitor = _janitor(monkeypatch, tmp_path, task_svc)
result = await janitor.run_cycle()
assert result["repaired"] == 1
note = writer.find_task_note(_TASK_ID)
assert note is not None
assert "status: in_progress" in note.read_text(encoding="utf-8")
# --- per-cycle cap + per-item isolation -------------------------------------- #
def _stub_id(n: int) -> str:
return f"aaaa{n:04d}-0000-0000-0000-000000000000"
@pytest.mark.asyncio
async def test_capped_cycle_resumes_tail_next_cycle(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
"""A capped tick advances last_sweep only to the max processed stamp, so
the next (immediately due again) tick drains the tail — no misses."""
monkeypatch.setattr(settings, "vault_report_enabled", False)
monkeypatch.setattr(
"roboco.services.vault_janitor._MAX_REPROJECT_PER_CYCLE", _TEST_CAP
)
old = datetime.now(UTC) - timedelta(days=3)
tasks = [
_task_stub(
id=_stub_id(i), title=f"Task {i}", updated_at=old + timedelta(minutes=i)
)
for i in range(_TEST_CAP + 1)
]
task_svc = _TaskSvcStub(changed=tasks)
janitor = _janitor(monkeypatch, tmp_path, task_svc)
writer = VaultWriter(tmp_path)
first = await janitor.run_cycle()
assert first["repaired"] == _TEST_CAP
assert writer.find_task_note(tasks[2].id) is None
state = json.loads(_state_file(tmp_path).read_text(encoding="utf-8"))
assert state["last_sweep"] == tasks[1].updated_at.isoformat()
await janitor.run_cycle()
assert writer.find_task_note(tasks[2].id) is not None
@pytest.mark.asyncio
async def test_raising_item_is_skipped_counted_and_state_advances(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
old = datetime.now(UTC) - timedelta(days=2)
bad = _task_stub(id=_stub_id(1), title="Bad", updated_at=old)
good = _task_stub(
id=_stub_id(2), title="Good", updated_at=old + timedelta(minutes=1)
)
task_svc = _TaskSvcStub(changed=[bad, good])
janitor = _janitor(monkeypatch, tmp_path, task_svc)
real = vault_assembly.reproject_task
async def flaky(writer: Any, tsvc: Any, psvc: Any, task: Any) -> Any:
if task.id == bad.id:
raise OSError("boom")
return await real(writer, tsvc, psvc, task)
with patch("roboco.services.vault_assembly.reproject_task", side_effect=flaky):
result = await janitor.run_cycle()
assert result["repaired"] == 1
assert result["failed"] == 1
writer = VaultWriter(tmp_path)
assert writer.find_task_note(good.id) is not None
assert writer.find_task_note(bad.id) is None
state = json.loads(_state_file(tmp_path).read_text(encoding="utf-8"))
assert datetime.fromisoformat(state["last_sweep"]) > _touched(good)
@pytest.mark.asyncio
async def test_capped_archive_advances_watermark_to_processed(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
"""A capped archive pass advances the watermark only to the last processed
candidate's terminal stamp (not the full cutoff) — the tail drains on the
next due sweep with no gap."""
monkeypatch.setattr(settings, "vault_report_enabled", False)
monkeypatch.setattr(settings, "vault_archive_days", 30)
monkeypatch.setattr("roboco.services.vault_janitor._MAX_ARCHIVE_PER_CYCLE", 1)
oldest = datetime.now(UTC) - timedelta(days=100)
older = datetime.now(UTC) - timedelta(days=90)
tasks = [
_task_stub(
id=_stub_id(1), title="Oldest", status="completed", completed_at=oldest
),
_task_stub(
id=_stub_id(2), title="Older", status="completed", completed_at=older
),
]
task_svc = _TaskSvcStub(archive=tasks)
janitor = _janitor(monkeypatch, tmp_path, task_svc)
result = await janitor.run_cycle()
assert result["archived"] == 1
writer = VaultWriter(tmp_path)
assert writer.find_task_note(tasks[0].id) is not None # oldest-first
assert writer.find_task_note(tasks[1].id) is None
state = json.loads(_state_file(tmp_path).read_text(encoding="utf-8"))
assert state["archive_watermark"] == oldest.isoformat()
# --- archival ---------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_archival_moves_old_terminal_note(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
monkeypatch.setattr(settings, "vault_archive_days", 30)
writer = VaultWriter(tmp_path)
live = writer.write_task(_note_data(status="completed"))
completed_at = datetime.now(UTC) - timedelta(days=90)
task = _task_stub(status="completed", completed_at=completed_at)
task_svc = _TaskSvcStub(archive=[task])
janitor = _janitor(monkeypatch, tmp_path, task_svc)
result = await janitor.run_cycle()
assert result["archived"] == 1
assert not live.exists()
note = writer.find_task_note(_TASK_ID)
assert note is not None
expected_prefix = tmp_path / "RoboCo" / "Archive" / str(completed_at.year) / "Tasks"
assert str(note).startswith(str(expected_prefix))
# A later re-projection finds the archived note — no duplicate appears.
data = await assemble_task_note_data(task_svc, MagicMock(), task)
assert writer.write_task(data) == note
assert writer.find_task_note(_TASK_ID) == note
@pytest.mark.asyncio
async def test_archive_days_zero_disables_archival(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
monkeypatch.setattr(settings, "vault_archive_days", 0)
task_svc = _TaskSvcStub(archive=[_task_stub(status="completed")])
janitor = _janitor(monkeypatch, tmp_path, task_svc)
result = await janitor.run_cycle()
assert result["archived"] == 0
assert "list_archive_candidates" not in task_svc.calls
# --- weekly report ------------------------------------------------------------ #
def _metrics_stub() -> MagicMock:
svc = MagicMock()
svc.get_velocity = AsyncMock(
return_value=SimpleNamespace(
tasks_completed=5,
tasks_created=8,
avg_completion_hours=12.5,
completion_rate=0.625,
)
)
svc.get_cycle_time_by_stage = AsyncMock(
return_value=[
SimpleNamespace(status="in_progress", avg_seconds=3600.0, sample_size=4)
]
)
svc.get_bottleneck_distribution = AsyncMock(
return_value=SimpleNamespace(
by_stage=[
SimpleNamespace(
status="awaiting_qa", cumulative_seconds=7200.0, pct_of_total=0.5
)
]
)
)
svc.get_rework_metrics = AsyncMock(
return_value=SimpleNamespace(
rate=0.2,
rework_cost_usd=1.23,
by_team=[SimpleNamespace(team="backend", rate=0.1)],
)
)
return svc
@pytest.mark.asyncio
async def test_weekly_report_written_once_per_week_and_notifies(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", True)
usage_svc = MagicMock()
usage_svc.get_summary = AsyncMock(
return_value={"total_cost_usd": 42.5, "total_tokens": 123456}
)
notifier = MagicMock()
notifier.send_weekly_report_notification = AsyncMock()
task_svc = _TaskSvcStub()
janitor = _janitor(monkeypatch, tmp_path, task_svc)
with (
patch(
"roboco.services.metrics.get_metrics_service",
return_value=_metrics_stub(),
),
patch("roboco.services.usage.get_usage_service", return_value=usage_svc),
patch(
"roboco.services.notification.NotificationService", return_value=notifier
),
):
await janitor.run_cycle()
await janitor.run_cycle() # same ISO week — no second report
week = _iso_week(datetime.now(UTC))
report = tmp_path / "RoboCo" / "Reports" / f"{week}.md"
assert report.exists()
notifier.send_weekly_report_notification.assert_awaited_once()
kwargs = notifier.send_weekly_report_notification.await_args.kwargs
assert kwargs["week"] == week
assert kwargs["note_path"] == str(report)
@pytest.mark.asyncio
async def test_weekly_report_skipped_when_report_flag_off(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", False)
janitor = _janitor(monkeypatch, tmp_path, _TaskSvcStub())
await janitor.run_cycle()
assert not (tmp_path / "RoboCo" / "Reports").exists()
@pytest.mark.asyncio
async def test_weekly_report_notification_failure_never_fails_sweep(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
) -> None:
monkeypatch.setattr(settings, "vault_report_enabled", True)
usage_svc = MagicMock()
usage_svc.get_summary = AsyncMock(
return_value={"total_cost_usd": 0.0, "total_tokens": 0}
)
notifier = MagicMock()
notifier.send_weekly_report_notification = AsyncMock(
side_effect=RuntimeError("smtp down")
)
janitor = _janitor(monkeypatch, tmp_path, _TaskSvcStub())
with (
patch(
"roboco.services.metrics.get_metrics_service",
return_value=_metrics_stub(),
),
patch("roboco.services.usage.get_usage_service", return_value=usage_svc),
patch(
"roboco.services.notification.NotificationService", return_value=notifier
),
):
await janitor.run_cycle()
week = _iso_week(datetime.now(UTC))
assert (tmp_path / "RoboCo" / "Reports" / f"{week}.md").exists()