mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(vault): Obsidian vault V1 — projection core, Auditor narrative, input loop (#458)
* fix(gateway): gate review diffs against the task's real parent branch (#444) The in-path PR-review gate's evidence diff (claim_gate_review) and the pr_pass conventions guard derived their diff base via parent_branch_for string surgery, which reuses the child branch's own team segment — wrong for every cross-team hop (a frontend child of a main_pm root derives a ref that never existed) and silently falls back to the repo default branch, so the reviewer judged the entire inherited base-branch content as the task's own work and failed acceptance criteria the task never touched. Bounced a live goals-tab fix three times, unfixable by branch surgery. The gate now resolves the base via resolve_parent_branch (the parent task's recorded branch_name, cross-team correct) and threads it as a new preferred_parent override through git.diff / list_changed_files / conventions_check_for_task — consulted only when no explicit base is given, so the pinned literal-base contract (base="HEAD~1") and every other diff caller (QA, doc, content) are byte-identical. Parent lookup fails open (derived-base fallback) like the other resolve_parent_branch call sites, and is skipped entirely while the conventions flag is off. Also excludes .uv-cache/ and .claude/ (agent worktrees, private uv cache) from the markdown prose scanner — both are repo-local tool dirs whose vendored/generated files tripped make reflow-check. Co-authored-by: Renn F <rennf93@users.noreply.github.com> * feat(vault): Obsidian vault V1 — projection core + input loop The vault is a rebuildable projection of the DB (never a source of truth), default-off behind ROBOCO_OBSIDIAN_VAULT_ENABLED + ROBOCO_VAULT_PATH. Projection core: VaultWriter materializes tasks/journals/A2A/agents as wikilinked markdown (id-suffixed stable filenames, alias-based links so renames never break, is_private journals excluded like the RAG corpus); event seams materialize on journal write and A2A send and touch task-note frontmatter at the status-transition chokepoint — all best-effort, a vault failure never blocks a verb. Shipped .obsidian config (Dataview, Kanban, team/status graph groups) + _meta dashboards; python -m roboco.vault rebuild/relocate (rebuild preserves the narrative section). Auditor narrative duty: curate_vault content verb (auditor-only, playbook-curation pattern) spawned by a dedicated root-completion hook with its own cooldown — fully separate from _dispatch_audit_work, whose scheduled-sweep/alert-producer revival belongs to the queued fleet task. Input loop: VaultIntakeEngine (ROBOCO_VAULT_INTAKE_*) watches the intake folder for #roboco-tagged notes and materializes each as ONE held draft (confirmed_by_human=False, Secretary-owned, source=vault_note, excluded by the dispatchers via _is_held_ceo_source) with local-model extraction and a deterministic fallback; vault_seen_notes ledger (migration 070) keyed on path+content-hash (the CEO-feedback callout is stripped before hashing so the engine's own append never self-triggers); per-cycle and open-draft caps. Nothing auto-starts. * fix(vault): integrate with master — re-chain migration 070 onto 069, mypy-clean tests The vault branch was cut from slave before the sequence-gate promotion, so migration 070 chained from 068 while master already carried 069 — two heads on merge. Master is now merged in and 070 revises 069. Vault test files also get mypy-clean mock idioms (monkeypatch.setattr over method assignment; await_args narrowed before access). * fix(scripts): dedupe SKIP_DIRS again after the master merge Master still carries the twin-merge duplicate (its dedupe hotfix is an unmerged PR); the merge re-imported it here. * fix(vault): reflow hard-wrapped prose in the vault asset templates * chore(config): exclude .uv-cache and .claude from deptry's scan scope Same repo-local tool dirs the prose scanner skips; the standalone deptry target walks the repo root and drowned in the cache's unpacked wheels. * fix(vault): board-review activation path for vault drafts + relocate graft The input loop's held-artifact posture was a dead end: vault_note drafts were unconditionally held by _is_held_ceo_source, owned by the verbless Secretary, hidden from the panel's approval surfaces by team, and the open-drafts cap counted them forever — the engine self-bricked after ten notes. Vault drafts now ride the intake board-review path instead: a tagged note becomes a PENDING Product-Owner-assigned Board draft (the exact confirm_live_draft board shape), the board reviews it, and only the CEO's approve_and_start makes it deliverable — never-auto-starts now rests on the board gate, proven by tests against the real dispatchers. The cap counts only drafts still awaiting the CEO (team==BOARD, non-terminal), so approval and cancellation both free it. relocate into an existing personal vault now grafts old_root/RoboCo as a direct child (refusing loudly if RoboCo/ already exists there) and adds only absent .obsidian/_meta files — a personal vault's config is never clobbered. An absent destination keeps the whole-tree move. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""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 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
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
Reference in New Issue
Block a user