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:
Renzo F
2026-07-11 03:17:59 +02:00
committed by GitHub
co-authored by Renn F
parent 340fcebce2
commit 15a3e87a2f
39 changed files with 3155 additions and 14 deletions
@@ -0,0 +1,34 @@
"""The Obsidian vault projection is gated by default-off config flags
(mirrors the roadmap engine / video engine idiom)."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
def test_obsidian_vault_disabled_by_default() -> None:
s = Settings()
assert s.obsidian_vault_enabled is False
assert s.vault_path == "/data/vault"
def test_obsidian_vault_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_OBSIDIAN_VAULT_ENABLED": "true"}):
assert Settings().obsidian_vault_enabled is True
def test_vault_path_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VAULT_PATH": "/mnt/my-vault"}):
assert Settings().vault_path == "/mnt/my-vault"
def test_obsidian_vault_flag_registered_in_feature_flags() -> None:
assert "obsidian_vault_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_obsidian_vault_flag_validates_as_bool() -> None:
validate_setting("obsidian_vault_enabled", "true")
@@ -0,0 +1,41 @@
"""Vault intake watcher — gated by default-off config flags (mirrors the
roadmap engine / X engine idiom)."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
_DEFAULT_INTERVAL = 300
_DEFAULT_MAX_PER_CYCLE = 3
_DEFAULT_MAX_OPEN_DRAFTS = 10
def test_vault_intake_disabled_by_default() -> None:
s = Settings()
assert s.vault_intake_enabled is False
assert s.vault_intake_interval_seconds == _DEFAULT_INTERVAL
assert s.vault_intake_dir == "RoboCo/Inbox"
assert s.vault_intake_max_per_cycle == _DEFAULT_MAX_PER_CYCLE
assert s.vault_intake_max_open_drafts == _DEFAULT_MAX_OPEN_DRAFTS
def test_vault_intake_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VAULT_INTAKE_ENABLED": "true"}):
assert Settings().vault_intake_enabled is True
def test_vault_intake_dir_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VAULT_INTAKE_DIR": "Foo/Bar"}):
assert Settings().vault_intake_dir == "Foo/Bar"
def test_vault_intake_flag_registered_in_feature_flags() -> None:
assert "vault_intake_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_vault_intake_flag_validates_as_bool() -> None:
validate_setting("vault_intake_enabled", "true")
+123
View File
@@ -0,0 +1,123 @@
"""curate_vault content verb — role grant + ContentActions RBAC + flag gating.
Mirrors test_playbook_verbs.py's shape: only the Auditor curates; a
delivery role is refused; the flag off short-circuits before any write.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
from roboco.services.gateway.role_config import get_role_config
def _actions(role: str) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = AsyncMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps)
def test_auditor_can_curate_vault() -> None:
assert "curate_vault" in get_role_config("auditor").do_tools
def test_developer_cannot_curate_vault() -> None:
assert "curate_vault" not in get_role_config("developer").do_tools
@pytest.mark.asyncio
async def test_curate_vault_forbidden_for_developer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
env = await _actions("developer").curate_vault(
agent_id=uuid4(), task_id=uuid4(), narrative="did stuff"
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_curate_vault_disabled_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
env = await _actions("auditor").curate_vault(
agent_id=uuid4(), task_id=uuid4(), narrative="did stuff"
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_curate_vault_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
actions = _actions("auditor")
actions.task.get = AsyncMock(return_value=None)
env = await actions.curate_vault(
agent_id=uuid4(), task_id=uuid4(), narrative="did stuff"
)
assert env.error == "not_found"
@pytest.mark.asyncio
async def test_curate_vault_writes_narrative_for_auditor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
actions = _actions("auditor")
task_id = uuid4()
task = MagicMock()
actions.task.get = AsyncMock(return_value=task)
writer = MagicMock()
data = MagicMock()
with (
patch(
"roboco.services.vault_assembly.assemble_task_note_data",
AsyncMock(return_value=data),
) as assemble,
patch("roboco.services.vault_writer.get_vault_writer", return_value=writer),
patch("roboco.services.project.get_project_service"),
):
env = await actions.curate_vault(
agent_id=uuid4(), task_id=task_id, narrative="Shipped after one rework."
)
assert env.error is None
assert env.status == "vault_curated"
assemble.assert_awaited_once()
assert assemble.await_args is not None
assert assemble.await_args.kwargs["narrative"] == "Shipped after one rework."
writer.write_task.assert_called_once_with(data)
@pytest.mark.asyncio
async def test_curate_vault_write_failure_returns_invalid_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
actions = _actions("auditor")
actions.task.get = AsyncMock(return_value=MagicMock())
with (
patch(
"roboco.services.vault_assembly.assemble_task_note_data",
AsyncMock(side_effect=OSError("disk full")),
),
patch("roboco.services.project.get_project_service"),
):
env = await actions.curate_vault(
agent_id=uuid4(), task_id=uuid4(), narrative="x"
)
assert env.error == "invalid_state"
@@ -55,6 +55,7 @@ def _make_orchestrator() -> AgentOrchestrator:
"_roadmap_engine_task",
"_x_feature_spotlight_task",
"_video_render_task",
"_vault_intake_task",
):
setattr(orch, attr, None)
return orch
@@ -0,0 +1,75 @@
"""Obsidian-vault root-completion Auditor spawn: gated on the flag, one-shot
per task (mirrors _dispatch_board_reviewer's guard shape), spawned WITHOUT a
bound task_id (mirrors _dispatch_audit_work's alert spawn — a `completed`
task_id would trip the readiness gate's role-for-status check).
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._instances = {}
orch._board_dispatched = set()
return orch
@pytest.mark.asyncio
async def test_dispatch_noop_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
orch = _make_orch()
with patch("roboco.db.base.get_db_context") as get_ctx:
await orch._dispatch_vault_curation_work(cast("Any", object()))
get_ctx.assert_not_called()
@pytest.mark.asyncio
async def test_maybe_spawn_vault_curation_spawns_auditor_without_task_id() -> None:
orch = _make_orch()
task_id = str(uuid4())
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_mark_vault_curation_dispatched", new=AsyncMock()),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._maybe_spawn_vault_curation(task_id, "A completed root")
spawn.assert_awaited_once()
assert spawn.await_args is not None
assert spawn.await_args.kwargs["agent_id"] == "auditor"
assert "task_id" not in spawn.await_args.kwargs
assert task_id in spawn.await_args.kwargs["initial_prompt"]
@pytest.mark.asyncio
async def test_maybe_spawn_vault_curation_one_shot() -> None:
orch = _make_orch()
task_id = str(uuid4())
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_mark_vault_curation_dispatched", new=AsyncMock()),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._maybe_spawn_vault_curation(task_id, "A completed root")
await orch._maybe_spawn_vault_curation(task_id, "A completed root")
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_maybe_spawn_vault_curation_skips_when_auditor_active() -> None:
orch = _make_orch()
task_id = str(uuid4())
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._maybe_spawn_vault_curation(task_id, "A completed root")
spawn.assert_not_awaited()
assert ("auditor", task_id) not in orch._board_dispatched
@@ -0,0 +1,47 @@
"""The vault-intake orchestrator loop is fully dormant unless BOTH the vault
AND intake flags are on (default).
With either flag off, ``_vault_intake_loop`` must return immediately — no
sleep, no DB, no filesystem scan — so a standard deployment behaves exactly
as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_vault_intake_loop_returns_immediately_when_both_flags_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "obsidian_vault_enabled", False)
monkeypatch.setattr(cfg, "vault_intake_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._vault_intake_loop(stub), timeout=1.0)
@pytest.mark.asyncio
async def test_vault_intake_loop_returns_immediately_when_intake_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "obsidian_vault_enabled", True)
monkeypatch.setattr(cfg, "vault_intake_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._vault_intake_loop(stub), timeout=1.0)
@pytest.mark.asyncio
async def test_vault_intake_loop_returns_immediately_when_vault_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "obsidian_vault_enabled", False)
monkeypatch.setattr(cfg, "vault_intake_enabled", True)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._vault_intake_loop(stub), timeout=1.0)
@@ -0,0 +1,439 @@
"""Vault intake watcher: #roboco-tagged notes become board-review drafts.
Mirrors the X-engine / roadmap-engine test shape. The engine opens a PENDING,
Product-Owner-assigned, team=board draft (source=vault_note) — the intake
"Board review & Start" shape. Nothing enters delivery until the CEO's
approve_and_start: the dispatch tests prove the draft routes ONLY to the
board review, and the cap tests prove approval/cancellation free the cap.
Asserted against a real Postgres DB.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, VaultSeenNoteTable
from roboco.foundation import identity as _foundation
from roboco.models.base import (
AgentRole,
AgentStatus,
TaskType,
Team,
)
from roboco.models.base import TaskStatus as TS
from roboco.runtime.orchestrator import AgentOrchestrator, _is_held_ceo_source
from roboco.services import vault_intake_engine as vie_module
from roboco.services.task import VAULT_NOTE_SOURCE, get_task_service
from roboco.services.vault_intake_engine import VaultIntakeEngine
from sqlalchemy import select
if TYPE_CHECKING:
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
)
await session.flush()
def _enable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, **overrides: object
) -> Path:
monkeypatch.setattr(cfg, "obsidian_vault_enabled", True)
monkeypatch.setattr(cfg, "vault_intake_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
monkeypatch.setattr(cfg, "vault_path", str(tmp_path))
monkeypatch.setattr(cfg, "vault_intake_dir", "RoboCo/Inbox")
monkeypatch.setattr(cfg, "vault_intake_max_per_cycle", 3)
monkeypatch.setattr(cfg, "vault_intake_max_open_drafts", 10)
for key, value in overrides.items():
monkeypatch.setattr(cfg, key, value)
inbox = tmp_path / "RoboCo" / "Inbox"
inbox.mkdir(parents=True)
return inbox
def _mock_local_model(monkeypatch: pytest.MonkeyPatch, reply: str | None) -> AsyncMock:
mock = AsyncMock(return_value=reply)
monkeypatch.setattr(vie_module, "_chat", mock)
return mock
def _write(inbox: Path, name: str, content: str) -> Path:
path = inbox / name
path.write_text(content, encoding="utf-8")
return path
_FRONTMATTER_TAGGED = "---\ntags: [roboco]\n---\n\n# Buy milk\n\nGet 2% milk.\n"
_INLINE_TAGGED = "# Fix the fence\n\n#roboco the fence is leaning.\n"
_UNTAGGED = "# Just a note\n\nNothing to see here.\n"
_WITH_CHECKBOXES = (
"---\ntags: [roboco]\n---\n\n# Weekend chores\n\n"
"- [ ] Mow the lawn\n- [ ] Wash the car\n"
)
# --------------------------------------------------------------------------- #
# Tag detection
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_frontmatter_tag_is_processed(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "a.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
drafts = await VaultIntakeEngine(db_session).run_cycle()
assert len(drafts) == ONE
@pytest.mark.asyncio
async def test_inline_tag_is_processed(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "b.md", _INLINE_TAGGED)
_mock_local_model(monkeypatch, None)
drafts = await VaultIntakeEngine(db_session).run_cycle()
assert len(drafts) == ONE
@pytest.mark.asyncio
async def test_untagged_note_is_ignored(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "c.md", _UNTAGGED)
_mock_local_model(monkeypatch, None)
drafts = await VaultIntakeEngine(db_session).run_cycle()
assert drafts == []
ledger = (await db_session.execute(select(VaultSeenNoteTable))).scalars().all()
assert ledger == []
# --------------------------------------------------------------------------- #
# Ledger dedup
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_unchanged_note_is_never_reprocessed(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "d.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
first = await engine.run_cycle()
assert len(first) == ONE
second = await engine.run_cycle()
assert second == []
@pytest.mark.asyncio
async def test_edited_note_is_eligible_again(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
path = _write(inbox, "e.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
first = await engine.run_cycle()
assert len(first) == ONE
# Edit the note's real content (not just appending the callout) — a new
# ledger key, so it's eligible again.
path.write_text(
path.read_text(encoding="utf-8") + "\nAlso get some bread.\n",
encoding="utf-8",
)
second = await engine.run_cycle()
assert len(second) == ONE
# --------------------------------------------------------------------------- #
# Caps
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_max_per_cycle_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path, vault_intake_max_per_cycle=2)
for i in range(4):
_write(inbox, f"note{i}.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
first = await engine.run_cycle()
assert len(first) == TWO
second = await engine.run_cycle()
assert len(second) == TWO
third = await engine.run_cycle()
assert third == []
@pytest.mark.asyncio
async def test_open_drafts_cap_skips_cycle(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path, vault_intake_max_open_drafts=1)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
_write(inbox, "first.md", _FRONTMATTER_TAGGED)
assert len(await engine.run_cycle()) == ONE # fills the cap
_write(inbox, "second.md", _INLINE_TAGGED)
drafts = await engine.run_cycle()
assert drafts == []
# The unprocessed note is NOT marked seen — eligible once the cap frees.
ledger = (await db_session.execute(select(VaultSeenNoteTable))).scalars().all()
assert len(ledger) == ONE
@pytest.mark.asyncio
async def test_cap_frees_after_approve_and_start(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""approve_and_start flips team to MAIN_PM — the draft leaves the cap AND
only then enters delivery (assigned to the Main PM)."""
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path, vault_intake_max_open_drafts=1)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
_write(inbox, "a.md", _FRONTMATTER_TAGGED)
task = (await engine.run_cycle())[0]
task_svc = get_task_service(db_session)
assert len(await task_svc.list_open_vault_note_drafts()) == ONE
task.board_review_complete = True # both reviewers done (server-side gate)
approved = await task_svc.approve_and_start(cast("Any", task.id))
assert approved is not None
assert approved.team == Team.MAIN_PM
assert approved.assigned_to == MAIN_PM_UUID # delivery starts HERE, not before
assert await task_svc.list_open_vault_note_drafts() == []
_write(inbox, "b.md", _INLINE_TAGGED)
assert len(await engine.run_cycle()) == ONE # cap freed
@pytest.mark.asyncio
async def test_cap_frees_after_cancellation(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path, vault_intake_max_open_drafts=1)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
_write(inbox, "a.md", _FRONTMATTER_TAGGED)
task = (await engine.run_cycle())[0]
task_svc = get_task_service(db_session)
assert len(await task_svc.list_open_vault_note_drafts()) == ONE
task.status = TS.CANCELLED
await db_session.flush()
assert await task_svc.list_open_vault_note_drafts() == []
_write(inbox, "b.md", _INLINE_TAGGED)
assert len(await engine.run_cycle()) == ONE # cap freed
# --------------------------------------------------------------------------- #
# Board-draft shape + dispatch routing (never auto-starts)
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_board_draft_shape(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "g.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
drafts = await VaultIntakeEngine(db_session).run_cycle()
task = drafts[0]
assert task.status == TS.PENDING
assert task.source == VAULT_NOTE_SOURCE
assert task.assigned_to == PO_UUID
assert task.team == Team.BOARD
assert task.task_type == TaskType.PLANNING # Main PM never owns code
# Board-routed intake shape: confirmed like a chat-confirmed draft — the
# board assignment + approve_and_start are the start gate, not this flag.
assert task.confirmed_by_human is True
def _vault_task_dict(**overrides: Any) -> dict[str, Any]:
task: dict[str, Any] = {
"id": "11111111-2222-3333-4444-555555555555",
"status": "pending",
"team": "board",
"title": "Vault note: buy milk",
"assigned_to": str(PO_UUID),
"source": VAULT_NOTE_SOURCE,
"orchestration_markers": None,
}
task.update(overrides)
return task
def test_vault_note_is_not_a_held_ceo_source() -> None:
"""The draft must DISPATCH (to board review) — a held-source skip would
strand it forever (no role can pull it, no review route exists)."""
assert _is_held_ceo_source(_vault_task_dict()) is False
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_vault_draft_to_board_review_only() -> None:
"""The PM dispatcher must hand a vault draft to the two-reviewer board
review and NOTHING else — no PM spawn, no delivery routing."""
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[_vault_task_dict()])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="product-owner")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._handle_board_assigned_task.assert_awaited_once()
stub._handle_pm_assigned_task.assert_not_awaited()
stub._route_unassigned_pm_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_vault_draft_never_dev_dispatched() -> None:
"""team=board fails _dev_dispatch_one's cell-team gate — a vault draft can
never spawn a developer, with or without any source-based skip."""
stub = MagicMock()
stub._spawn_pending_dev = AsyncMock()
stub._handle_dev_existing_owner = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dev_dispatch_one(
cast("AgentOrchestrator", stub), client, _vault_task_dict()
)
stub._spawn_pending_dev.assert_not_awaited()
stub._handle_dev_existing_owner.assert_not_awaited()
# --------------------------------------------------------------------------- #
# Local-model fallback
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_local_model_failure_falls_back_to_deterministic(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "h.md", _WITH_CHECKBOXES)
monkeypatch.setattr(
vie_module, "_chat", AsyncMock(side_effect=RuntimeError("local model down"))
)
drafts = await VaultIntakeEngine(db_session).run_cycle()
task = drafts[0]
assert "Weekend chores" in task.title
assert "Mow the lawn" in task.acceptance_criteria
assert "Wash the car" in task.acceptance_criteria
@pytest.mark.asyncio
async def test_local_model_success_is_used(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
_write(inbox, "i.md", _FRONTMATTER_TAGGED)
_mock_local_model(
monkeypatch,
'{"title": "Milk run", "description": "Pick up milk on the way home.", '
'"action_items": ["Buy 2% milk"]}',
)
drafts = await VaultIntakeEngine(db_session).run_cycle()
task = drafts[0]
assert "Milk run" in task.title
assert task.description == "Pick up milk on the way home."
assert task.acceptance_criteria == ["Buy 2% milk"]
# --------------------------------------------------------------------------- #
# Feedback callout
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_feedback_callout_appended_once(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
await _seed(db_session)
inbox = _enable(monkeypatch, tmp_path)
path = _write(inbox, "j.md", _FRONTMATTER_TAGGED)
_mock_local_model(monkeypatch, None)
engine = VaultIntakeEngine(db_session)
await engine.run_cycle()
text = path.read_text(encoding="utf-8")
assert text.count("RoboCo: drafted") == ONE
# A second cycle over the now-callout-bearing (but otherwise unchanged)
# note must not reprocess it or double the callout.
second = await engine.run_cycle()
assert second == []
assert path.read_text(encoding="utf-8").count("RoboCo: drafted") == ONE
+187
View File
@@ -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",
)
+235
View File
@@ -0,0 +1,235 @@
"""VaultWriter — pure materializer: layout, idempotency, wikilink shape.
No DB, no flag checks (those are the seam callers' job) — just entity ->
markdown, given a tmp_path vault root.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import yaml
from roboco.services.vault_writer import (
A2AMessageData,
AgentNoteData,
JournalNoteData,
TaskLinkRef,
TaskNoteData,
VaultWriter,
)
if TYPE_CHECKING:
from pathlib import Path
_PRIORITY = 2
def _task_data(**overrides: Any) -> TaskNoteData:
base: dict[str, Any] = {
"id": "11112222-3333-4444-5555-666677778888",
"title": "Add user authentication endpoint",
"project_slug": "roboco-api",
"description": "Implement the login endpoint.",
"status": "in_progress",
"team": "backend",
"priority": _PRIORITY,
"task_type": "code",
}
base.update(overrides)
return TaskNoteData(**base)
# --- layout ------------------------------------------------------------- #
def test_write_task_layout_and_frontmatter(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
path = writer.write_task(_task_data())
assert path == (
tmp_path
/ "RoboCo"
/ "Tasks"
/ "roboco-api"
/ "Add user authentication endpoint (11112222).md"
)
text = path.read_text(encoding="utf-8")
assert text.startswith("---\n")
fm, _, _ = text.removeprefix("---\n").partition("\n---\n")
frontmatter = yaml.safe_load(fm)
assert frontmatter["aliases"] == ["11112222"]
assert frontmatter["status"] == "in_progress"
assert frontmatter["team"] == "backend"
assert frontmatter["priority"] == _PRIORITY
assert "# Add user authentication endpoint" in text
assert "Implement the login endpoint." in text
assert "## Narrative" in text
assert "_Pending Auditor curation._" in text
def test_write_journal_entry_layout(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
path = writer.write_journal_entry(
JournalNoteData(
entry_id="aaaa1111-0000-0000-0000-000000000000",
agent_slug="be-dev-1",
scope="learning",
title="Retry flaky pg connections",
content="Backoff worked.",
timestamp=datetime(2026, 7, 10, tzinfo=UTC),
)
)
assert path == (
tmp_path
/ "RoboCo"
/ "Journals"
/ "be-dev-1"
/ "2026-07-10 Retry flaky pg connections (aaaa1111).md"
)
text = path.read_text(encoding="utf-8")
assert "agent: be-dev-1" in text
assert "scope: learning" in text
assert "Backoff worked." in text
def test_write_agent_layout(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
path = writer.write_agent(
AgentNoteData(
slug="be-dev-1", name="BE Dev 1", role="developer", team="backend"
)
)
assert path == tmp_path / "RoboCo" / "Agents" / "be-dev-1.md"
text = path.read_text(encoding="utf-8")
assert "role: developer" in text
assert "BE Dev 1" in text
# --- idempotency / rename stability -------------------------------------- #
def test_write_task_idempotent_same_input(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
p1 = writer.write_task(_task_data())
p2 = writer.write_task(_task_data())
assert p1 == p2
assert p1.read_text(encoding="utf-8") == p2.read_text(encoding="utf-8")
def test_write_task_title_rename_keeps_filename(tmp_path: Path) -> None:
"""A rename updates the title line, not the filename (link stability)."""
writer = VaultWriter(tmp_path)
original = writer.write_task(_task_data())
renamed = writer.write_task(_task_data(title="Add auth endpoint (renamed)"))
assert original == renamed
text = renamed.read_text(encoding="utf-8")
assert "# Add auth endpoint (renamed)" in text
def test_append_a2a_message_idempotent_per_message_id(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
data = A2AMessageData(
conversation_id="cccc0000-0000-0000-0000-000000000000",
message_id="msg-0001",
from_agent="be-dev-1",
to_agent="be-pm",
content="ping",
timestamp=datetime(2026, 7, 10, 9, 0, 0, tzinfo=UTC),
)
path1 = writer.append_a2a_message(data)
path2 = writer.append_a2a_message(data) # same message id — retry
assert path1 == path2
text = path1.read_text(encoding="utf-8")
assert text.count("<!-- msg:msg-0001 -->") == 1
# --- wikilink shape ------------------------------------------------------- #
def test_task_wikilinks_include_parent_subtasks_dependencies(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
parent = TaskLinkRef(id="99998888-0000-0000-0000-000000000000", title="Parent task")
sub = TaskLinkRef(id="77776666-0000-0000-0000-000000000000", title="Subtask one")
dep = TaskLinkRef(id="55554444-0000-0000-0000-000000000000", title="Dep task")
path = writer.write_task(
_task_data(parent=parent, subtasks=(sub,), dependencies=(dep,))
)
text = path.read_text(encoding="utf-8")
assert "[[99998888|Parent task]]" in text
assert "[[77776666|Subtask one]]" in text
assert "[[55554444|Dep task]]" in text
def test_journal_task_ref_link_without_title(tmp_path: Path) -> None:
"""Journal seam links a task without fetching its title (id-only ok)."""
writer = VaultWriter(tmp_path)
task_ref = TaskLinkRef(id="12341234-0000-0000-0000-000000000000")
path = writer.write_journal_entry(
JournalNoteData(
entry_id="bbbb2222-0000-0000-0000-000000000000",
agent_slug="be-dev-1",
scope="note",
title="Quick note",
content="body",
timestamp=datetime(2026, 7, 10, tzinfo=UTC),
task_ref=task_ref,
)
)
text = path.read_text(encoding="utf-8")
assert "[[12341234]]" in text
# --- cheap frontmatter touch ---------------------------------------------- #
def test_touch_task_frontmatter_noop_when_note_missing(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
touched = writer.touch_task_frontmatter(
task_id="00001111-0000-0000-0000-000000000000",
status="claimed",
team="backend",
pr_number=None,
pr_url=None,
)
assert touched is False
def test_touch_task_frontmatter_patches_existing_note(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
path = writer.write_task(_task_data(status="pending"))
touched = writer.touch_task_frontmatter(
task_id="11112222-3333-4444-5555-666677778888",
status="in_progress",
team="backend",
pr_number=42,
pr_url="https://github.com/x/y/pull/42",
)
assert touched is True
text = path.read_text(encoding="utf-8")
assert "status: in_progress" in text
assert "pr: https://github.com/x/y/pull/42" in text
# body/narrative untouched by the cheap touch
assert "## Narrative" in text
assert "Implement the login endpoint." in text
# --- rebuild-preserves-narrative helper ------------------------------------ #
def test_existing_narrative_none_for_placeholder(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
writer.write_task(_task_data())
assert (
writer.existing_narrative("roboco-api", "11112222-3333-4444-5555-666677778888")
is None
)
def test_existing_narrative_preserved_when_curated(tmp_path: Path) -> None:
writer = VaultWriter(tmp_path)
writer.write_task(_task_data(narrative="Shipped cleanly, one rework cycle."))
assert (
writer.existing_narrative("roboco-api", "11112222-3333-4444-5555-666677778888")
== "Shipped cleanly, one rework cycle."
)
+202
View File
@@ -0,0 +1,202 @@
"""``python -m roboco.vault`` — rebuild / relocate, on a tmp vault.
Flag-gated: both subcommands refuse when ROBOCO_OBSIDIAN_VAULT_ENABLED is off.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from roboco.config import settings
from roboco.services.vault_writer import TaskNoteData
from roboco.vault import ensure_vault_assets, main
if TYPE_CHECKING:
from pathlib import Path
import pytest
def test_main_refuses_rebuild_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
assert main(["rebuild"]) == 1
def test_main_refuses_relocate_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "obsidian_vault_enabled", False)
assert main(["relocate", "/tmp/somewhere"]) == 1
def test_relocate_moves_existing_tree(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
old_root = tmp_path / "old-vault"
old_root.mkdir()
(old_root / "RoboCo").mkdir()
(old_root / "RoboCo" / "marker.md").write_text("hi", encoding="utf-8")
new_root = tmp_path / "new-vault"
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(old_root))
assert main(["relocate", str(new_root)]) == 0
assert not old_root.exists()
assert (new_root / "RoboCo" / "marker.md").read_text(encoding="utf-8") == "hi"
def test_relocate_into_existing_vault_grafts_roboco_subtree(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An existing destination is a personal vault: only RoboCo/ moves into it
(never nesting the old dirname), the personal .obsidian is never
clobbered, and absent shipped assets are added."""
old_root = tmp_path / "old-vault"
(old_root / "RoboCo").mkdir(parents=True)
(old_root / "RoboCo" / "marker.md").write_text("hi", encoding="utf-8")
personal = tmp_path / "personal-vault"
(personal / ".obsidian").mkdir(parents=True)
(personal / ".obsidian" / "app.json").write_text("personal", encoding="utf-8")
(personal / "My Notes").mkdir()
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(old_root))
assert main(["relocate", str(personal)]) == 0
assert (personal / "RoboCo" / "marker.md").read_text(encoding="utf-8") == "hi"
assert not (personal / "old-vault").exists() # no nested old dirname
assert not (old_root / "RoboCo").exists() # subtree moved, not copied
# Personal config untouched; absent shipped assets materialized.
assert (personal / ".obsidian" / "app.json").read_text(
encoding="utf-8"
) == "personal"
assert (personal / ".obsidian" / "community-plugins.json").exists()
assert (personal / "My Notes").exists()
def test_relocate_refuses_when_destination_roboco_exists(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
old_root = tmp_path / "old-vault"
(old_root / "RoboCo").mkdir(parents=True)
(old_root / "RoboCo" / "marker.md").write_text("hi", encoding="utf-8")
personal = tmp_path / "personal-vault"
(personal / "RoboCo").mkdir(parents=True)
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(old_root))
assert main(["relocate", str(personal)]) == 1
# Nothing moved on refusal.
assert (old_root / "RoboCo" / "marker.md").exists()
def test_ensure_vault_assets_materializes_templates_idempotently(
tmp_path: Path,
) -> None:
ensure_vault_assets(tmp_path)
plugins = tmp_path / ".obsidian" / "community-plugins.json"
dashboard = tmp_path / "RoboCo" / "_meta" / "dashboard.md"
kanban = tmp_path / "RoboCo" / "_meta" / "kanban-board.md"
assert plugins.exists()
assert dashboard.exists()
assert kanban.exists()
assert "dataview" in plugins.read_text(encoding="utf-8")
# An operator's own edit survives a second call (never overwritten).
dashboard.write_text("operator edit", encoding="utf-8")
ensure_vault_assets(tmp_path)
assert dashboard.read_text(encoding="utf-8") == "operator edit"
def _db_ctx(db: Any) -> Any:
@asynccontextmanager
async def _ctx() -> Any:
yield db
return _ctx
def test_rebuild_writes_agent_task_journal_and_a2a(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``main()`` drives ``_rebuild`` via ``asyncio.run`` — this test must
stay a plain (non-async) function, or pytest-asyncio's own running loop
collides with it."""
monkeypatch.setattr(settings, "obsidian_vault_enabled", True)
monkeypatch.setattr(settings, "vault_path", str(tmp_path))
agent = MagicMock(
id=uuid4(),
slug="be-dev-1",
name="BE Dev 1",
role="developer",
team=SimpleNamespace(value="backend"),
)
agent_service = MagicMock()
agent_service.list_agents = AsyncMock(return_value=[agent])
task = MagicMock(id=uuid4(), project_id=None)
task_service = MagicMock()
task_service.list_all = AsyncMock(side_effect=[[task], []])
journal = MagicMock(id=uuid4())
entry = MagicMock(
id=uuid4(),
task_id=None,
title="Learned something",
content="body",
timestamp=datetime.now(UTC),
type="learning",
)
journal_service = MagicMock()
journal_service.get_or_create_journal = AsyncMock(return_value=journal)
journal_service.list_entries = AsyncMock(side_effect=[[entry], []])
conv = MagicMock(id=uuid4(), agent_a="be-dev-1", agent_b="be-pm", task_id=None)
conv_result = MagicMock()
conv_result.scalars.return_value.all.return_value = [conv]
db = MagicMock()
db.execute = AsyncMock(return_value=conv_result)
a2a_msg = MagicMock(
id=uuid4(), from_agent="be-dev-1", content="hi", created_at=datetime.now(UTC)
)
a2a_service = MagicMock()
a2a_service.get_messages = AsyncMock(return_value=[a2a_msg])
task_note_data = TaskNoteData(
id=str(task.id),
title="A task",
project_slug="unassigned",
description="desc",
status="completed",
team="backend",
priority=2,
task_type="code",
)
with (
patch("roboco.db.base.get_db_context", _db_ctx(db)),
patch("roboco.services.agent.AgentService", return_value=agent_service),
patch("roboco.services.task.TaskService", return_value=task_service),
patch("roboco.services.journal.JournalService", return_value=journal_service),
patch("roboco.services.project.get_project_service", return_value=MagicMock()),
patch("roboco.services.a2a.A2AService", return_value=a2a_service),
patch(
"roboco.services.vault_assembly.assemble_task_note_data",
AsyncMock(return_value=task_note_data),
),
):
assert main(["rebuild"]) == 0
assert (tmp_path / "RoboCo" / "Agents" / "be-dev-1.md").exists()
assert any((tmp_path / "RoboCo" / "Tasks" / "unassigned").glob("*.md"))
assert any((tmp_path / "RoboCo" / "Journals" / "be-dev-1").glob("*.md"))
assert any((tmp_path / "RoboCo" / "A2A").glob("*.md"))
# asset bootstrap ran too
assert (tmp_path / ".obsidian" / "community-plugins.json").exists()