mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
230 lines
8.6 KiB
Python
230 lines
8.6 KiB
Python
"""``python -m roboco.vault {rebuild|relocate <new-path>}``.
|
|
|
|
``rebuild``: full re-projection of every live entity (agents, tasks, journal
|
|
entries, A2A threads) from the DB into the vault, plus materializing the
|
|
shipped ``.obsidian/`` config + ``RoboCo/_meta/`` dashboards from packaged
|
|
templates (``roboco/vault_assets/``) if not already present. A task's
|
|
``## Narrative`` (Auditor-authored, not derivable from DB state) is read back
|
|
from the existing note and preserved across the rebuild. Archive-aware: an
|
|
old terminal task projects straight into ``RoboCo/Archive/<year>/`` (same
|
|
``VaultWriter.write_task`` path the drift janitor's archival pass uses).
|
|
|
|
``relocate <new-path>``: move the vault tree to a new location. Notes use
|
|
relative/alias-based wikilinks, so nothing inside them needs rewriting. An
|
|
already-existing destination (a personal vault) receives only the ``RoboCo/``
|
|
subtree plus any absent shipped assets — its own ``.obsidian`` is never
|
|
touched.
|
|
|
|
Both are inert unless ``ROBOCO_OBSIDIAN_VAULT_ENABLED`` is on (rebuild would
|
|
otherwise materialize a vault nobody reads).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import shutil
|
|
import sys
|
|
from datetime import UTC, datetime
|
|
from importlib import resources
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from roboco.config import settings
|
|
|
|
|
|
def ensure_vault_assets(vault_root: Path) -> None:
|
|
"""Materialize ``.obsidian/`` + ``RoboCo/_meta/`` from packaged templates.
|
|
|
|
Never overwrites a file that already exists — an operator's own edits to
|
|
the shipped config/dashboards survive both a later rebuild and repeated
|
|
startup calls (idempotent, cheap when everything's already there).
|
|
"""
|
|
assets = resources.files("roboco.vault_assets")
|
|
_copy_tree(assets.joinpath("obsidian"), vault_root / ".obsidian")
|
|
_copy_tree(assets.joinpath("meta"), vault_root / "RoboCo" / "_meta")
|
|
|
|
|
|
def _copy_tree(src: Any, dest: Path) -> None:
|
|
for entry in src.iterdir():
|
|
target = dest / entry.name
|
|
if entry.is_dir():
|
|
_copy_tree(entry, target)
|
|
elif not target.exists():
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(entry.read_bytes())
|
|
|
|
|
|
async def _rebuild_agents(writer: Any, agent_service: Any) -> list[Any]:
|
|
from roboco.services.vault_writer import AgentNoteData
|
|
|
|
agents = await agent_service.list_agents()
|
|
for agent in agents:
|
|
writer.write_agent(
|
|
AgentNoteData(
|
|
slug=agent.slug,
|
|
name=agent.name,
|
|
role=str(getattr(agent.role, "value", agent.role)),
|
|
team=str(agent.team.value) if agent.team else None,
|
|
)
|
|
)
|
|
return list(agents)
|
|
|
|
|
|
async def _rebuild_tasks(writer: Any, task_service: Any, project_service: Any) -> None:
|
|
from roboco.services.vault_assembly import reproject_task
|
|
|
|
offset = 0
|
|
while True:
|
|
tasks = await task_service.list_all(limit=100, offset=offset)
|
|
if not tasks:
|
|
break
|
|
for task in tasks:
|
|
await reproject_task(writer, task_service, project_service, task)
|
|
offset += len(tasks)
|
|
|
|
|
|
async def _rebuild_journals(
|
|
writer: Any, journal_service: Any, agents: list[Any]
|
|
) -> None:
|
|
from roboco.foundation.policy.journaling import TYPE_TO_SCOPE
|
|
from roboco.models.journal import ListEntriesFilter
|
|
from roboco.services.vault_writer import JournalNoteData, TaskLinkRef
|
|
|
|
for agent in agents:
|
|
journal = await journal_service.get_or_create_journal(agent.id)
|
|
offset = 0
|
|
while True:
|
|
entries = await journal_service.list_entries(
|
|
journal.id, ListEntriesFilter(limit=100, offset=offset)
|
|
)
|
|
if not entries:
|
|
break
|
|
for entry in entries:
|
|
scope = TYPE_TO_SCOPE.get(entry.type)
|
|
task_ref = TaskLinkRef(id=str(entry.task_id)) if entry.task_id else None
|
|
writer.write_journal_entry(
|
|
JournalNoteData(
|
|
entry_id=str(entry.id),
|
|
agent_slug=agent.slug,
|
|
scope=scope.value if scope else str(entry.type),
|
|
title=entry.title,
|
|
content=entry.content,
|
|
timestamp=entry.timestamp,
|
|
task_ref=task_ref,
|
|
)
|
|
)
|
|
offset += len(entries)
|
|
|
|
|
|
async def _rebuild_a2a(writer: Any, db: Any) -> None:
|
|
from sqlalchemy import select
|
|
|
|
from roboco.db.tables import A2AConversationTable
|
|
from roboco.services.a2a import A2AService
|
|
from roboco.services.vault_writer import A2AMessageData, TaskLinkRef
|
|
|
|
a2a_service = A2AService(db)
|
|
conv_rows = (await db.execute(select(A2AConversationTable))).scalars().all()
|
|
epoch = datetime.min.replace(tzinfo=UTC)
|
|
for conv in conv_rows:
|
|
messages = await a2a_service.get_messages(conv.id, conv.agent_a, limit=500)
|
|
task_ref = TaskLinkRef(id=str(conv.task_id)) if conv.task_id else None
|
|
for msg in sorted(messages, key=lambda m: m.created_at or epoch):
|
|
writer.append_a2a_message(
|
|
A2AMessageData(
|
|
conversation_id=str(conv.id),
|
|
message_id=str(msg.id),
|
|
from_agent=msg.from_agent,
|
|
to_agent=(
|
|
conv.agent_b if msg.from_agent == conv.agent_a else conv.agent_a
|
|
),
|
|
content=msg.content,
|
|
timestamp=msg.created_at or epoch,
|
|
task_ref=task_ref,
|
|
)
|
|
)
|
|
|
|
|
|
async def _rebuild(vault_root: Path) -> None:
|
|
from roboco.db.base import get_db_context
|
|
from roboco.services.agent import AgentService
|
|
from roboco.services.journal import JournalService
|
|
from roboco.services.project import get_project_service
|
|
from roboco.services.task import TaskService
|
|
from roboco.services.vault_writer import VaultWriter
|
|
|
|
writer = VaultWriter(vault_root)
|
|
async with get_db_context() as db:
|
|
agent_service = AgentService(db)
|
|
task_service = TaskService(db)
|
|
journal_service = JournalService(db)
|
|
project_service = get_project_service(db)
|
|
|
|
agents = await _rebuild_agents(writer, agent_service)
|
|
await _rebuild_tasks(writer, task_service, project_service)
|
|
await _rebuild_journals(writer, journal_service, agents)
|
|
await _rebuild_a2a(writer, db)
|
|
|
|
ensure_vault_assets(vault_root)
|
|
|
|
|
|
def _relocate(new_path: Path) -> int:
|
|
"""Move the vault to ``new_path``; 0 on success, 1 on refusal.
|
|
|
|
An EXISTING destination is a personal vault: graft only the ``RoboCo/``
|
|
subtree into it (``shutil.move`` of the whole root would nest the old
|
|
dirname inside it) and materialize the shipped ``.obsidian``/``_meta``
|
|
assets only where absent — never clobbering the vault's own config. An
|
|
absent destination gets the whole-tree move.
|
|
"""
|
|
old_root = Path(settings.vault_path)
|
|
if not old_root.exists() or old_root == new_path:
|
|
new_path.mkdir(parents=True, exist_ok=True)
|
|
elif new_path.exists():
|
|
dest_tree = new_path / "RoboCo"
|
|
if dest_tree.exists():
|
|
print(
|
|
f"Refusing to relocate: {dest_tree} already exists. Move or "
|
|
"remove it first.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
old_tree = old_root / "RoboCo"
|
|
if old_tree.exists():
|
|
shutil.move(str(old_tree), str(dest_tree))
|
|
ensure_vault_assets(new_path)
|
|
else:
|
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(old_root), str(new_path))
|
|
print(
|
|
f"Vault moved to {new_path}. Set ROBOCO_VAULT_PATH={new_path} in the "
|
|
"environment for this to persist across restarts."
|
|
)
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(prog="python -m roboco.vault")
|
|
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
subcommands.add_parser("rebuild", help="full re-projection from the DB")
|
|
relocate = subcommands.add_parser("relocate", help="move the vault tree")
|
|
relocate.add_argument("new_path", type=Path)
|
|
args = parser.parse_args(argv)
|
|
|
|
if not settings.obsidian_vault_enabled:
|
|
print(
|
|
"ROBOCO_OBSIDIAN_VAULT_ENABLED is off — nothing to do.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
if args.command == "rebuild":
|
|
asyncio.run(_rebuild(Path(settings.vault_path)))
|
|
print(f"Vault rebuilt at {settings.vault_path}")
|
|
return 0
|
|
return _relocate(args.new_path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|