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:
@@ -19,5 +19,6 @@
|
||||
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
|
||||
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
|
||||
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
|
||||
| `curate_vault` | `curate_vault(task_id: UUID, narrative: str)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
|
||||
@@ -251,6 +251,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `approve_playbook` | `approve_playbook(playbook_id: UUID)` |
|
||||
| `reject_playbook` | `reject_playbook(playbook_id: UUID, reason: str)` |
|
||||
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
|
||||
| `curate_vault` | `curate_vault(task_id: UUID, narrative: str)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
|
||||
|
||||
@@ -29,3 +29,8 @@ You silently observe org activity and log anomalies. You do **not** communicate
|
||||
|
||||
## Principle
|
||||
Observe, don't interfere. The CEO reads your reflect-notes when reviewing org health.
|
||||
|
||||
## Vault curation (Obsidian)
|
||||
When a root task completes, you may be spawned specifically to curate its Obsidian-vault note (feature-flagged, no-op when disabled). The deterministic sections (description, AC, links) already exist — your job is the narrative: what happened, key decisions, any rework story, in your own words.
|
||||
- `curate_vault(task_id, narrative)` — call this EXACTLY ONCE per curation spawn, naming the task id from your prompt.
|
||||
- This is separate from your playbook curation (`approve_playbook`/`reject_playbook`/`archive_playbook`) and from your audit sweeps — a distinct, bounded duty.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Add vault_seen_notes table — the vault-intake watcher's dedup ledger.
|
||||
|
||||
Keyed by (vault-relative path, content hash) together: an unchanged note
|
||||
never reprocesses, but an edited note (new hash) is eligible again. Mirrors
|
||||
``x_seen_mentions``. Additive and inert while ``ROBOCO_VAULT_INTAKE_ENABLED``
|
||||
is off.
|
||||
|
||||
Revision ID: 070_vault_seen_notes
|
||||
Revises: 069_tasks_parent_task_id_idx
|
||||
Create Date: 2026-07-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "070_vault_seen_notes"
|
||||
down_revision = "069_tasks_parent_task_id_idx"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"vault_seen_notes",
|
||||
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
sa.Column("note_path", sa.String(length=512), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"processed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"note_path", "content_hash", name="uq_vault_seen_notes_path_hash"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("vault_seen_notes")
|
||||
+5
-1
@@ -202,6 +202,10 @@ select = [
|
||||
# imports so the prompt-composition utility stays decoupled from the heavy
|
||||
# service graph (it loads at every agent spawn).
|
||||
"roboco/agents/factories/_base.py" = ["PLC0415"]
|
||||
# roboco/vault.py's cheap ensure_vault_assets() is imported at orchestrator
|
||||
# startup; _rebuild()'s full service graph (Task/Journal/A2A/Agent services)
|
||||
# stays deferred so that startup path never pays for it.
|
||||
"roboco/vault.py" = ["PLC0415"]
|
||||
# Test fixtures that reload modules to test env-var-at-import-time behavior.
|
||||
# ARG002: ApiClient-subclassing fakes must keep the superclass parameter names
|
||||
# for mypy's override check, so unused override-stub args can't be renamed.
|
||||
@@ -380,7 +384,7 @@ ignore = []
|
||||
# Deptry Configuration
|
||||
# =============================================================================
|
||||
[tool.deptry]
|
||||
exclude = ["tests", ".venv", "vulture_whitelist.py", "alembic"]
|
||||
exclude = ["tests", ".venv", ".uv-cache", ".claude", "vulture_whitelist.py", "alembic"]
|
||||
extend_exclude = ["conftest.py", "setup.py"]
|
||||
known_first_party = ["roboco"]
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from roboco.api.schemas.v1.do import (
|
||||
ApprovePlaybookRequest,
|
||||
ArchivePlaybookRequest,
|
||||
CommitRequest,
|
||||
CurateVaultRequest,
|
||||
DmRequest,
|
||||
DraftPlaybookRequest,
|
||||
EvidenceRequest,
|
||||
@@ -460,3 +461,21 @@ async def do_archive_playbook(
|
||||
agent_id=x_agent_id, playbook_id=body.playbook_id
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/curate_vault")
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.max_request_size(size_bytes=65536)
|
||||
@guard_deco.custom_validation(secret_exfil_validator)
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.behavior_analysis(_RUNAWAY_RULES)
|
||||
async def do_curate_vault(
|
||||
request: Request,
|
||||
body: CurateVaultRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.curate_vault(
|
||||
agent_id=x_agent_id, task_id=body.task_id, narrative=body.narrative
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
@@ -278,3 +278,10 @@ class ArchivePlaybookRequest(BaseModel):
|
||||
"""Auditor archives a playbook (-> archived)."""
|
||||
|
||||
playbook_id: UUID
|
||||
|
||||
|
||||
class CurateVaultRequest(BaseModel):
|
||||
"""Auditor writes a root task-tree's vault narrative section."""
|
||||
|
||||
task_id: UUID
|
||||
narrative: str = Field(..., min_length=1)
|
||||
|
||||
@@ -1604,6 +1604,61 @@ class Settings(BaseSettings):
|
||||
description="Banned single-word commit subjects",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Obsidian vault projection (V1 — projection core) — DEFAULT OFF
|
||||
# ==========================================================================
|
||||
# The org's human-readable memory palace: tasks/journals/A2A conversations
|
||||
# materialize as markdown notes with wikilinks, browsable in Obsidian
|
||||
# (Dataview/Kanban/graph plugins). Off by default and fully inert — every
|
||||
# seam (journal write, A2A send, task transition) no-ops when off.
|
||||
obsidian_vault_enabled: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Master switch for the Obsidian vault projection. OFF by default; "
|
||||
"when off no note is ever written and every event seam is a no-op."
|
||||
),
|
||||
)
|
||||
vault_path: str = Field(
|
||||
default="/data/vault",
|
||||
description=(
|
||||
"Root directory the vault materializes into (bind-mounted on the "
|
||||
"NAS in production). Only consulted when obsidian_vault_enabled."
|
||||
),
|
||||
)
|
||||
|
||||
# Vault intake — the vexa-inspired input loop (V1 item 4): notes tagged
|
||||
# #roboco in an opt-in vault folder become HELD intake drafts. Inert
|
||||
# unless BOTH obsidian_vault_enabled AND vault_intake_enabled are on.
|
||||
vault_intake_enabled: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Master switch for the vault intake watcher. OFF by default; when "
|
||||
"off no note is ever scanned and nothing is drafted."
|
||||
),
|
||||
)
|
||||
vault_intake_interval_seconds: int = Field(
|
||||
default=300,
|
||||
ge=30,
|
||||
description="Seconds between vault-intake scan cycles.",
|
||||
)
|
||||
vault_intake_dir: str = Field(
|
||||
default="RoboCo/Inbox",
|
||||
description=("Vault-relative folder scanned for tagged notes (non-recursive)."),
|
||||
)
|
||||
vault_intake_max_per_cycle: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
description="Max held drafts the intake watcher may originate in one cycle.",
|
||||
)
|
||||
vault_intake_max_open_drafts: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
description=(
|
||||
"Rolling cap on concurrently-open held vault-note drafts; the "
|
||||
"watcher originates nothing more past it."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_uvicorn_loop_factory(
|
||||
loop: Literal["asyncio", "uvloop"],
|
||||
|
||||
@@ -2308,6 +2308,30 @@ class XSeenFeatureTable(Base):
|
||||
)
|
||||
|
||||
|
||||
class VaultSeenNoteTable(Base):
|
||||
"""Dedup ledger for the vault-intake watcher — one row per (vault-relative
|
||||
path, content hash) pair already turned into a held draft. Unique on both
|
||||
columns together (not just path): an unchanged note never reprocesses, but
|
||||
an edited note (new hash) is eligible again. Mirrors XSeenMentionTable."""
|
||||
|
||||
__tablename__ = "vault_seen_notes"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
note_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
processed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"note_path", "content_hash", name="uq_vault_seen_notes_path_hash"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TIKTOK ACCOUNT TABLES
|
||||
# =============================================================================
|
||||
|
||||
@@ -46,6 +46,8 @@ X_SPOTLIGHT_SKIP_REASON = "x_spotlight_skip_reason"
|
||||
ROADMAP_CYCLE = "roadmap_cycle"
|
||||
VIDEO_DRAFT = "video_draft"
|
||||
VIDEO_REJECT_REASON = "video_reject_reason"
|
||||
VAULT_CURATION_DISPATCHED = "vault_curation_dispatched"
|
||||
VAULT_NOTE_REF = "vault_note_ref"
|
||||
|
||||
|
||||
def get_marker(task: HasMarkers, key: str, default: Any = None) -> Any:
|
||||
@@ -279,6 +281,36 @@ def set_video_reject_reason(task: HasMarkers, reason: str) -> None:
|
||||
set_marker(task, VIDEO_REJECT_REASON, reason)
|
||||
|
||||
|
||||
# --- vault curation ---------------------------------------------------------
|
||||
# One-shot guard for the root-completion Auditor spawn (orchestrator): set the
|
||||
# moment the spawn fires so a restart can't re-spawn a root another process
|
||||
# instance already dispatched (in-memory `_board_dispatched` covers the
|
||||
# same-process race; this covers cross-restart).
|
||||
|
||||
|
||||
def is_vault_curation_dispatched(task: HasMarkers) -> bool:
|
||||
return bool(get_marker(task, VAULT_CURATION_DISPATCHED, False))
|
||||
|
||||
|
||||
def mark_vault_curation_dispatched(task: HasMarkers) -> None:
|
||||
set_marker(task, VAULT_CURATION_DISPATCHED, True)
|
||||
|
||||
|
||||
# --- vault note ref ----------------------------------------------------------
|
||||
# The vault-intake watcher's origin ref on a held ``vault_note`` draft:
|
||||
# {path, content_hash, action_items}. Set once at origination; read back by
|
||||
# nothing else yet (phase 2 could use it to re-locate the source note).
|
||||
|
||||
|
||||
def get_vault_note_ref(task: HasMarkers) -> dict[str, Any] | None:
|
||||
val = get_marker(task, VAULT_NOTE_REF)
|
||||
return val if isinstance(val, dict) else None
|
||||
|
||||
|
||||
def set_vault_note_ref(task: HasMarkers, ref: dict[str, Any]) -> None:
|
||||
set_marker(task, VAULT_NOTE_REF, ref)
|
||||
|
||||
|
||||
# --- external PR head ------------------------------------------------------ #
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ SCOPE_TO_TYPE: dict[Scope, JournalEntryType] = {
|
||||
Scope.STRUGGLE: JournalEntryType.STRUGGLE,
|
||||
}
|
||||
|
||||
# Reverse of the above — used by the vault projection to render a journal
|
||||
# entry's agent-facing scope label (not the raw SQLAlchemy enum name) into
|
||||
# frontmatter.
|
||||
TYPE_TO_SCOPE: dict[JournalEntryType, Scope] = {v: k for k, v in SCOPE_TO_TYPE.items()}
|
||||
|
||||
|
||||
class ReadTier(StrEnum):
|
||||
"""How widely a role can read other agents' journals."""
|
||||
|
||||
@@ -789,6 +789,16 @@ def archive_playbook(playbook_id: str) -> dict[str, Any]:
|
||||
return _post("/api/v1/do/archive_playbook", {"playbook_id": playbook_id})
|
||||
|
||||
|
||||
def curate_vault(task_id: str, narrative: str) -> dict[str, Any]:
|
||||
"""Auditor only: write a root task-tree's Obsidian-vault narrative section
|
||||
(what happened, decisions, rework story). No-op error if the vault flag
|
||||
is off."""
|
||||
return _post(
|
||||
"/api/v1/do/curate_vault",
|
||||
{"task_id": task_id, "narrative": narrative},
|
||||
)
|
||||
|
||||
|
||||
# ---------- Wave 1 — pre-gateway parity ----------
|
||||
|
||||
|
||||
@@ -955,6 +965,7 @@ _TOOLS: dict[str, Any] = {
|
||||
"approve_playbook": approve_playbook,
|
||||
"reject_playbook": reject_playbook,
|
||||
"archive_playbook": archive_playbook,
|
||||
"curate_vault": curate_vault,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+164
-12
@@ -785,7 +785,10 @@ def _is_held_ceo_source(task: dict[str, Any]) -> bool:
|
||||
External-PR review (owned by the PR dispatcher), release proposals, X
|
||||
posts/replies, and video-post drafts (all CEO-HELD, acted on only by
|
||||
their own routes), and a self-heal fix task until the CEO's
|
||||
approve_and_start flips ``confirmed_by_human``. Module-level (not a
|
||||
approve_and_start flips ``confirmed_by_human``. A ``vault_note`` draft is
|
||||
NOT held: it is a board-assigned intake draft, so this dispatcher's board
|
||||
branch routes it to the board review (its start gate is the CEO's
|
||||
approve_and_start, like any board-routed draft). Module-level (not a
|
||||
method) so the dispatcher's unit tests, which drive it with a
|
||||
wholesale-mocked ``self``, exercise the real skip logic rather than an
|
||||
auto-mocked stub.
|
||||
@@ -925,17 +928,7 @@ class AgentOrchestrator:
|
||||
self._last_image_prune: datetime | None = None
|
||||
# Rate-limit probe loop: 30-second interval, scans Redis for all
|
||||
# rate-limited providers and resolves waiting agents on success.
|
||||
self._rate_limit_probe_task: asyncio.Task | None = None
|
||||
self._strategy_engine_task: asyncio.Task | None = None
|
||||
self._external_pr_poll_task: asyncio.Task | None = None
|
||||
self._self_heal_task: asyncio.Task | None = None
|
||||
self._ci_watch_task: asyncio.Task | None = None
|
||||
self._dep_update_task: asyncio.Task | None = None
|
||||
self._release_manager_task: asyncio.Task | None = None
|
||||
self._x_mentions_task: asyncio.Task | None = None
|
||||
self._roadmap_engine_task: asyncio.Task | None = None
|
||||
self._x_feature_spotlight_task: asyncio.Task | None = None
|
||||
self._video_render_task: asyncio.Task | None = None
|
||||
self._init_engine_loop_task_slots()
|
||||
# per-engine-loop heartbeat (monotonic last-success, interval) so
|
||||
# _check_loop_liveness can alert when a cycle task dies silently.
|
||||
self._loop_heartbeats: dict[str, tuple[float, float]] = {}
|
||||
@@ -1060,6 +1053,23 @@ class AgentOrchestrator:
|
||||
self._grok_last_park_at: datetime | None = None
|
||||
self._grok_repark_count: int = 0
|
||||
|
||||
def _init_engine_loop_task_slots(self) -> None:
|
||||
"""Task handles for the default-off engine loops. Split out of
|
||||
__init__ (rather than inlined) to keep it under the statement budget
|
||||
as more engine loops accrete over time."""
|
||||
self._rate_limit_probe_task: asyncio.Task | None = None
|
||||
self._strategy_engine_task: asyncio.Task | None = None
|
||||
self._external_pr_poll_task: asyncio.Task | None = None
|
||||
self._self_heal_task: asyncio.Task | None = None
|
||||
self._ci_watch_task: asyncio.Task | None = None
|
||||
self._dep_update_task: asyncio.Task | None = None
|
||||
self._release_manager_task: asyncio.Task | None = None
|
||||
self._x_mentions_task: asyncio.Task | None = None
|
||||
self._roadmap_engine_task: asyncio.Task | None = None
|
||||
self._x_feature_spotlight_task: asyncio.Task | None = None
|
||||
self._video_render_task: asyncio.Task | None = None
|
||||
self._vault_intake_task: asyncio.Task | None = None
|
||||
|
||||
def _record_loop_heartbeat(self, name: str, interval: float) -> None:
|
||||
self._loop_heartbeats[name] = (time.monotonic(), interval)
|
||||
|
||||
@@ -1132,6 +1142,11 @@ class AgentOrchestrator:
|
||||
|
||||
await sweep_orphan_release_locks()
|
||||
|
||||
# Obsidian vault: materialize the shipped .obsidian/ + _meta/ template
|
||||
# assets on first enable (idempotent — never overwrites an operator's
|
||||
# own edits). No-op when the flag is off.
|
||||
self._ensure_vault_assets_on_startup()
|
||||
|
||||
# Start background tasks
|
||||
self._health_task = asyncio.create_task(self._health_loop())
|
||||
self._dispatcher_task = asyncio.create_task(self._dispatcher_loop())
|
||||
@@ -1149,6 +1164,7 @@ class AgentOrchestrator:
|
||||
self._x_feature_spotlight_loop()
|
||||
)
|
||||
self._video_render_task = asyncio.create_task(self._video_render_loop())
|
||||
self._vault_intake_task = asyncio.create_task(self._vault_intake_loop())
|
||||
|
||||
logger.info(
|
||||
"Orchestrator started",
|
||||
@@ -1156,6 +1172,19 @@ class AgentOrchestrator:
|
||||
internal_api_url=self._api_url,
|
||||
)
|
||||
|
||||
def _ensure_vault_assets_on_startup(self) -> None:
|
||||
"""Best-effort, gated: never blocks/fails startup on a vault error."""
|
||||
if not settings.obsidian_vault_enabled:
|
||||
return
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
from roboco.vault import ensure_vault_assets
|
||||
|
||||
ensure_vault_assets(Path(settings.vault_path))
|
||||
except Exception as e:
|
||||
logger.warning("Vault asset bootstrap failed at startup", error=str(e))
|
||||
|
||||
async def _cancel_background_task(self, task: asyncio.Task | None) -> None:
|
||||
"""Cancel one background loop task and await its teardown (idempotent)."""
|
||||
if task is None:
|
||||
@@ -1247,6 +1276,7 @@ class AgentOrchestrator:
|
||||
self._roadmap_engine_task,
|
||||
self._x_feature_spotlight_task,
|
||||
self._video_render_task,
|
||||
self._vault_intake_task,
|
||||
):
|
||||
await self._cancel_background_task(task)
|
||||
|
||||
@@ -8035,6 +8065,36 @@ Start by:
|
||||
await get_roadmap_engine(db).run_cycle()
|
||||
await db.commit()
|
||||
|
||||
async def _vault_intake_loop(self) -> None:
|
||||
"""Vault intake: on an interval, turn tagged notes into held drafts.
|
||||
|
||||
Dormant unless BOTH ``obsidian_vault_enabled`` AND
|
||||
``vault_intake_enabled`` are on — a standard deployment scans nothing.
|
||||
Every draft is held for the CEO; this loop never starts anything.
|
||||
"""
|
||||
if not (settings.obsidian_vault_enabled and settings.vault_intake_enabled):
|
||||
return
|
||||
interval = settings.vault_intake_interval_seconds
|
||||
self._record_loop_heartbeat("vault_intake", interval)
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
await self._run_vault_intake_cycle()
|
||||
self._record_loop_heartbeat("vault_intake", interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("vault-intake cycle failed")
|
||||
|
||||
async def _run_vault_intake_cycle(self) -> None:
|
||||
"""One vault-intake pass: run the engine, commit. Testable w/o the sleep."""
|
||||
from roboco.db import get_db_context
|
||||
from roboco.services.vault_intake_engine import get_vault_intake_engine
|
||||
|
||||
async with get_db_context() as db:
|
||||
await get_vault_intake_engine(db).run_cycle()
|
||||
await db.commit()
|
||||
|
||||
async def _x_feature_spotlight_loop(self) -> None:
|
||||
"""X engine: on an interval, open ONE held feature-spotlight exploration
|
||||
for the Head of Marketing.
|
||||
@@ -10855,6 +10915,7 @@ Start now: evidence(task_id="{task_id}")
|
||||
("approval_work", self._dispatch_approval_work(client)),
|
||||
("a2a_work", self._dispatch_a2a_work(client)),
|
||||
("audit_work", self._dispatch_audit_work(client)),
|
||||
("vault_curation_work", self._dispatch_vault_curation_work(client)),
|
||||
("detect_stuck_tasks", self._detect_stuck_tasks(client)),
|
||||
]
|
||||
for name, coro in dispatchers:
|
||||
@@ -11909,6 +11970,82 @@ Start now: evidence(task_id="{task_id}")
|
||||
for task in tasks:
|
||||
await self._maybe_spawn_pm_closure(client, task)
|
||||
|
||||
async def _dispatch_vault_curation_work(self, _client: httpx.AsyncClient) -> None:
|
||||
"""Obsidian-vault root-completion hook: spawn the Auditor to write a
|
||||
just-completed root task-tree's narrative.
|
||||
|
||||
Gated on ``ROBOCO_OBSIDIAN_VAULT_ENABLED``; a no-op scan otherwise.
|
||||
Owns ONLY this vault-curation trigger — distinct from
|
||||
``_dispatch_audit_work`` (scheduled sweeps + alert producers, owned by
|
||||
a separate, queued fleet task). ``_client`` is accepted for
|
||||
dispatcher-tuple shape parity but unused — this reads the DB
|
||||
directly (see ``TaskService.list_completed_roots_pending_vault_curation``).
|
||||
"""
|
||||
if not settings.obsidian_vault_enabled:
|
||||
return
|
||||
from roboco.db.base import get_db_context
|
||||
from roboco.services.task import TaskService
|
||||
|
||||
async with get_db_context() as db:
|
||||
candidates = await TaskService(
|
||||
db
|
||||
).list_completed_roots_pending_vault_curation()
|
||||
for task in candidates:
|
||||
await self._maybe_spawn_vault_curation(str(task.id), task.title)
|
||||
|
||||
async def _maybe_spawn_vault_curation(self, task_id: str, title: str) -> None:
|
||||
"""One-shot Auditor spawn for one completed root task.
|
||||
|
||||
Mirrors ``_dispatch_board_reviewer``'s guard shape: an in-memory
|
||||
one-shot tracker (reuses ``_board_dispatched``) for the same-process
|
||||
race, plus a durable ``vault_curation_dispatched`` marker so a
|
||||
restart can't re-spawn a root another process instance already
|
||||
handled. Spawned WITHOUT a bound task_id (mirrors
|
||||
``_dispatch_audit_work``'s alert spawn) — the root task is
|
||||
`completed`, so binding it would trip the readiness gate's
|
||||
role-for-status check; the task id is named in the prompt instead,
|
||||
and ``curate_vault`` takes it as an explicit argument.
|
||||
"""
|
||||
auditor_slug = "auditor"
|
||||
if self._is_agent_active(auditor_slug):
|
||||
return
|
||||
key = (auditor_slug, task_id)
|
||||
if key in self._board_dispatched:
|
||||
return
|
||||
self._board_dispatched.add(key)
|
||||
await self._mark_vault_curation_dispatched(task_id)
|
||||
logger.info("Spawning Auditor for vault curation", task_id=task_id)
|
||||
await self.spawn_agent(
|
||||
agent_id=auditor_slug,
|
||||
initial_prompt=self._build_vault_curation_prompt(task_id, title),
|
||||
spawned_by="_maybe_spawn_vault_curation",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _mark_vault_curation_dispatched(task_id: str) -> None:
|
||||
"""Persist the one-shot marker so a restart never re-spawns this
|
||||
root. Best-effort: a failure here only risks a harmless duplicate
|
||||
Auditor spawn (curate_vault's write is idempotent), never a crash."""
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.db.base import get_db_context
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.services.task import TaskService
|
||||
|
||||
try:
|
||||
async with get_db_context() as db:
|
||||
svc = TaskService(db)
|
||||
task = await svc.get(UUID(task_id))
|
||||
if task is not None:
|
||||
markers.mark_vault_curation_dispatched(task)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to persist vault_curation_dispatched marker",
|
||||
task_id=task_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
async def _fetch_subtasks(
|
||||
self, client: httpx.AsyncClient, parent_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -13985,6 +14122,21 @@ Your job:
|
||||
3. Identify any concerns or patterns
|
||||
4. Compile audit report for CEO
|
||||
5. Call i_am_idle() when complete
|
||||
"""
|
||||
|
||||
def _build_vault_curation_prompt(self, task_id: str, title: str) -> str:
|
||||
"""Build initial prompt for a root-completion Obsidian-vault curation."""
|
||||
return f"""Root task COMPLETED — Obsidian-vault curation requested.
|
||||
|
||||
TASK: {title} ({task_id})
|
||||
|
||||
Your job:
|
||||
|
||||
1. Review this task's full tree (description, subtasks, PR, journal trail,
|
||||
decisions, any rework story).
|
||||
2. Call curate_vault(task_id="{task_id}", narrative=...) EXACTLY ONCE with a
|
||||
concise narrative: what happened, key decisions, rework if any.
|
||||
3. Call i_am_idle() when complete.
|
||||
"""
|
||||
|
||||
def _build_a2a_prompt(self, notification: dict[str, Any]) -> str:
|
||||
|
||||
@@ -1343,8 +1343,45 @@ class A2AService:
|
||||
await self._publish_a2a_message_sent(
|
||||
model, task_id, from_agent, to_agent, skill
|
||||
)
|
||||
await self._materialize_vault_note(model, conv, from_agent, to_agent)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
async def _materialize_vault_note(
|
||||
msg: A2AChatMessage,
|
||||
conv: A2AConversationTable,
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
) -> None:
|
||||
"""Best-effort Obsidian-vault thread digest (event seam).
|
||||
|
||||
A vault failure never fails the A2A send — logged and swallowed,
|
||||
same posture as ``_publish_a2a_message_sent``.
|
||||
"""
|
||||
if not settings.obsidian_vault_enabled:
|
||||
return
|
||||
try:
|
||||
from roboco.services.vault_writer import (
|
||||
A2AMessageData,
|
||||
TaskLinkRef,
|
||||
get_vault_writer,
|
||||
)
|
||||
|
||||
task_ref = TaskLinkRef(id=str(conv.task_id)) if conv.task_id else None
|
||||
get_vault_writer().append_a2a_message(
|
||||
A2AMessageData(
|
||||
conversation_id=str(conv.id),
|
||||
message_id=str(msg.id),
|
||||
from_agent=from_agent,
|
||||
to_agent=to_agent,
|
||||
content=msg.content,
|
||||
timestamp=msg.created_at or datetime.now(UTC),
|
||||
task_ref=task_ref,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Vault A2A note materialization failed", error=str(e))
|
||||
|
||||
async def get_messages(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
|
||||
@@ -333,6 +333,10 @@ _DRAFT_PLAYBOOK_ROLES: frozenset[str] = frozenset(
|
||||
)
|
||||
_CURATE_PLAYBOOK_ROLES: frozenset[str] = frozenset({"auditor"})
|
||||
|
||||
# Vault-curation: only the Auditor writes a root task-tree's narrative
|
||||
# (mirrors the playbook-curation bounded-expansion pattern above).
|
||||
_CURATE_VAULT_ROLES: frozenset[str] = frozenset({"auditor"})
|
||||
|
||||
# propose_video's target-platform set + TikTok caption limit (the X caption
|
||||
# reuses MAX_TWEET_CHARS). No role frozenset here, unlike the sets above:
|
||||
# propose_video is gated on the caller's TEAM at runtime (_caller_team), not
|
||||
@@ -985,6 +989,60 @@ class ContentActions:
|
||||
},
|
||||
)
|
||||
|
||||
async def curate_vault(
|
||||
self, *, agent_id: UUID, task_id: UUID, narrative: str
|
||||
) -> Envelope:
|
||||
"""Auditor-only: write a root task-tree's vault narrative section.
|
||||
|
||||
Fully re-materializes the task's note (parent/subtasks/dependencies
|
||||
resolved fresh) with ``narrative`` filling the ``## Narrative``
|
||||
section a deterministic write otherwise leaves as a placeholder.
|
||||
"""
|
||||
role = await self._caller_role(agent_id)
|
||||
if role not in _CURATE_VAULT_ROLES:
|
||||
return Envelope.not_authorized(
|
||||
message=f"role {role!r} may not curate the vault",
|
||||
remediate="Only the Auditor writes vault narratives.",
|
||||
context_briefing={},
|
||||
)
|
||||
if not settings.obsidian_vault_enabled:
|
||||
return Envelope.invalid_state(
|
||||
message="the Obsidian vault is disabled",
|
||||
remediate="ROBOCO_OBSIDIAN_VAULT_ENABLED is off — nothing to curate.",
|
||||
context_briefing={},
|
||||
)
|
||||
task = await self.task.get(task_id)
|
||||
if task is None:
|
||||
return Envelope.not_found(message=f"task {task_id} not found")
|
||||
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.vault_assembly import assemble_task_note_data
|
||||
from roboco.services.vault_writer import get_vault_writer
|
||||
|
||||
try:
|
||||
data = await assemble_task_note_data(
|
||||
self.task,
|
||||
get_project_service(self.task.session),
|
||||
task,
|
||||
narrative=narrative,
|
||||
)
|
||||
get_vault_writer().write_task(data)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"vault curation write failed", task_id=str(task_id), error=str(exc)
|
||||
)
|
||||
return Envelope.invalid_state(
|
||||
message=f"vault write failed: {exc}",
|
||||
remediate="retry curate_vault; check ROBOCO_VAULT_PATH is writable",
|
||||
context_briefing={},
|
||||
)
|
||||
return Envelope.ok(
|
||||
status="vault_curated",
|
||||
task_id=str(task_id),
|
||||
next="continue",
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
async def _record_section_handoff(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -143,6 +143,7 @@ _AUDITOR_DO = (
|
||||
"approve_playbook",
|
||||
"reject_playbook",
|
||||
"archive_playbook",
|
||||
"curate_vault",
|
||||
"notify_list",
|
||||
"notify_get",
|
||||
)
|
||||
|
||||
@@ -301,6 +301,9 @@ class JournalService(BaseService):
|
||||
),
|
||||
is_private=entry_create.is_private,
|
||||
)
|
||||
await self._materialize_vault_note(
|
||||
entry_row, agent_id_for_index, is_private=entry_create.is_private
|
||||
)
|
||||
|
||||
return JournalEntry(
|
||||
id=require_uuid(entry_row.id),
|
||||
@@ -316,6 +319,52 @@ class JournalService(BaseService):
|
||||
created_at=entry_row.created_at,
|
||||
)
|
||||
|
||||
async def _materialize_vault_note(
|
||||
self, entry_row: JournalEntryTable, agent_id: UUID, *, is_private: bool
|
||||
) -> None:
|
||||
"""Best-effort Obsidian-vault note for this entry (event seam).
|
||||
|
||||
Mirrors the RAG-index exclusion: a private entry never leaves the
|
||||
agent's own journal, vault included. A vault failure NEVER fails the
|
||||
journal write — logged and swallowed, same posture as the RAG index.
|
||||
"""
|
||||
from roboco.config import settings
|
||||
|
||||
if is_private or not settings.obsidian_vault_enabled:
|
||||
return
|
||||
try:
|
||||
from roboco.foundation.policy.journaling import TYPE_TO_SCOPE
|
||||
from roboco.services.vault_writer import (
|
||||
JournalNoteData,
|
||||
TaskLinkRef,
|
||||
get_vault_writer,
|
||||
)
|
||||
|
||||
agent_slug = await self.get_agent_slug(agent_id)
|
||||
if not agent_slug:
|
||||
return
|
||||
scope = TYPE_TO_SCOPE.get(entry_row.type)
|
||||
task_ref = (
|
||||
TaskLinkRef(id=str(entry_row.task_id)) if entry_row.task_id else None
|
||||
)
|
||||
get_vault_writer().write_journal_entry(
|
||||
JournalNoteData(
|
||||
entry_id=str(entry_row.id),
|
||||
agent_slug=agent_slug,
|
||||
scope=scope.value if scope else str(entry_row.type),
|
||||
title=entry_row.title,
|
||||
content=entry_row.content,
|
||||
timestamp=entry_row.timestamp,
|
||||
task_ref=task_ref,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
"Vault note materialization failed (best-effort)",
|
||||
entry_id=str(entry_row.id),
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def _schedule_rag_index(
|
||||
self, params: IndexJournalEntryParams, *, is_private: bool
|
||||
) -> None:
|
||||
|
||||
@@ -70,6 +70,8 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
|
||||
("video_on_spotlight", "Video on feature spotlight"),
|
||||
("roadmap_engine_enabled", "Board roadmap engine"),
|
||||
("fable_mode_enabled", "Fable + Ponytail doctrine (+ hooks)"),
|
||||
("obsidian_vault_enabled", "Obsidian vault projection"),
|
||||
("vault_intake_enabled", "Vault intake watcher (notes -> held drafts)"),
|
||||
)
|
||||
_FEATURE_FLAG_KEYS = tuple(key for key, _ in FEATURE_FLAGS)
|
||||
|
||||
|
||||
+89
-1
@@ -610,6 +610,14 @@ ROADMAP_SOURCE = "board_roadmap"
|
||||
# (distinct from ROADMAP_SOURCE, which tags the held exploration cycle itself).
|
||||
ROADMAP_ITEM_SOURCE = "roadmap"
|
||||
|
||||
# Source tag for an intake draft the vault-intake watcher originates from a
|
||||
# #roboco-tagged vault note. Unlike X_SOURCES/VIDEO_HELD_SOURCES this IS
|
||||
# dispatched — it rides the intake board-review path (PENDING, Product-Owner-
|
||||
# assigned, team=board, confirmed_by_human=True, exactly the "Board review &
|
||||
# Start" shape): the board reviews, the CEO's approve_and_start hands it to
|
||||
# the Main PM. The board routing is the start gate; no held-source skip.
|
||||
VAULT_NOTE_SOURCE = "vault_note"
|
||||
|
||||
|
||||
def extract_self_heal_fingerprint(task: Any) -> str | None:
|
||||
"""The self-heal dedupe fingerprint from a task's markers, or None.
|
||||
@@ -854,6 +862,38 @@ class TaskService(BaseService):
|
||||
details=dict(details),
|
||||
)
|
||||
)
|
||||
self._touch_vault_frontmatter(task, to_status=to_status, team=details["team"])
|
||||
|
||||
def _touch_vault_frontmatter(
|
||||
self, task: TaskTable, *, to_status: str, team: str
|
||||
) -> None:
|
||||
"""Best-effort, cheap Obsidian-vault frontmatter touch (event seam).
|
||||
|
||||
Only patches an EXISTING note's status/team/pr fields — no extra
|
||||
queries (everything used here is already a plain column on ``task``).
|
||||
A vault failure (or a not-yet-materialized note) never blocks the
|
||||
transition; see ``VaultWriter.touch_task_frontmatter``.
|
||||
"""
|
||||
from roboco.config import settings
|
||||
|
||||
if not settings.obsidian_vault_enabled:
|
||||
return
|
||||
try:
|
||||
from roboco.services.vault_writer import get_vault_writer
|
||||
|
||||
get_vault_writer().touch_task_frontmatter(
|
||||
task_id=str(task.id),
|
||||
status=to_status,
|
||||
team=team,
|
||||
pr_number=task.pr_number,
|
||||
pr_url=task.pr_url,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
"Vault frontmatter touch failed (best-effort)",
|
||||
task_id=str(task.id),
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def _emit_escalation_audit(
|
||||
self, task: TaskTable, *, escalator_slug: str, target_slug: str
|
||||
@@ -1605,6 +1645,24 @@ class TaskService(BaseService):
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_open_vault_note_drafts(self) -> list[TaskTable]:
|
||||
"""Vault-note drafts still awaiting board review / CEO approval — the
|
||||
vault-intake watcher's open-cap basis. Keys on ``team == BOARD``:
|
||||
``approve_and_start`` flips the team to MAIN_PM (draft leaves the cap)
|
||||
and a cancelled/rejected draft goes terminal (leaves too), so the cap
|
||||
counts only drafts actually pending a human decision — it can never
|
||||
permanently brick the engine. Ordered oldest-first."""
|
||||
result = await self.session.execute(
|
||||
select(TaskTable)
|
||||
.where(
|
||||
TaskTable.source == VAULT_NOTE_SOURCE,
|
||||
TaskTable.team == Team.BOARD,
|
||||
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
|
||||
)
|
||||
.order_by(TaskTable.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_external_pr_reviews_awaiting_decision(self) -> list[TaskTable]:
|
||||
"""Completed external-PR reviews still awaiting the CEO's decision.
|
||||
|
||||
@@ -6839,6 +6897,34 @@ class TaskService(BaseService):
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_completed_roots_pending_vault_curation(
|
||||
self, *, limit: int = 20
|
||||
) -> list[TaskTable]:
|
||||
"""Root tasks (no parent) most recently completed, not yet vault-curated.
|
||||
|
||||
Backs the orchestrator's root-completion Auditor spawn. Ordered by
|
||||
``completed_at`` DESC within a small window — self-bounding without a
|
||||
migration: ``orchestration_markers`` is a plain JSON column (no
|
||||
server-side "key absent" filter), so the marker check runs client-side
|
||||
over a small recency window instead of the whole completed-task table,
|
||||
which would otherwise grow forever and could mask a fresh completion
|
||||
behind older, already-curated rows.
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(TaskTable)
|
||||
.where(
|
||||
TaskTable.status == TaskStatus.COMPLETED,
|
||||
TaskTable.parent_task_id.is_(None),
|
||||
)
|
||||
.order_by(TaskTable.completed_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [
|
||||
t
|
||||
for t in result.scalars().all()
|
||||
if not markers.is_vault_curation_dispatched(t)
|
||||
]
|
||||
|
||||
async def list_all(
|
||||
self,
|
||||
limit: int = 100,
|
||||
@@ -8261,7 +8347,9 @@ class TaskService(BaseService):
|
||||
reason. ``VIDEO_HELD_SOURCES`` (``video_post``) gets the identical
|
||||
treatment; the video-authoring source (``video``) is NOT held — it is
|
||||
pre-assigned to a ux-dev and must still be offered like any other
|
||||
delegated code task.
|
||||
delegated code task. A ``vault_note`` draft is NOT held either — like a
|
||||
prompter board draft, its board assignment IS the gate (board roles have
|
||||
no ``give_me_work``), so no source exclusion is needed.
|
||||
|
||||
Ordered by sequence asc, then priority asc, then created_at asc so
|
||||
earlier-sequence tasks win.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Assembles ``VaultWriter`` dataclasses from live DB state.
|
||||
|
||||
Kept separate from ``vault_writer`` (which is a pure, DB-free materializer)
|
||||
so that module stays trivially unit-testable with tmp_path. Shared by the
|
||||
Auditor's ``curate_vault`` verb and the ``python -m roboco.vault rebuild`` CLI
|
||||
— both need "task + parent + subtasks + dependencies + project slug" turned
|
||||
into a ``TaskNoteData``.
|
||||
|
||||
Services are passed in as ``Any`` (duck-typed) rather than imported by type,
|
||||
so this module never needs ``roboco.services.task`` / ``roboco.services.project``
|
||||
at import time — avoids a cycle with ``TaskService`` (which itself calls into
|
||||
the vault seam on status transitions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from roboco.services.vault_writer import TaskLinkRef, TaskNoteData
|
||||
|
||||
|
||||
def _enum_value(value: Any) -> str:
|
||||
return value.value if hasattr(value, "value") else str(value)
|
||||
|
||||
|
||||
async def _resolve_project_slug(project_service: Any, task: Any) -> str:
|
||||
if task.project_id is None:
|
||||
return "unassigned"
|
||||
project = await project_service.get(task.project_id)
|
||||
return project.slug if project is not None else "unassigned"
|
||||
|
||||
|
||||
async def _resolve_parent(task_service: Any, task: Any) -> TaskLinkRef | None:
|
||||
if task.parent_task_id is None:
|
||||
return None
|
||||
parent_task = await task_service.get(task.parent_task_id)
|
||||
if parent_task is None:
|
||||
return None
|
||||
return TaskLinkRef(id=str(parent_task.id), title=parent_task.title)
|
||||
|
||||
|
||||
async def _resolve_subtasks(task_service: Any, task: Any) -> tuple[TaskLinkRef, ...]:
|
||||
return tuple(
|
||||
TaskLinkRef(id=str(t.id), title=t.title)
|
||||
for t in await task_service.get_subtasks(task.id)
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_dependencies(
|
||||
task_service: Any, task: Any
|
||||
) -> tuple[TaskLinkRef, ...]:
|
||||
dependencies: list[TaskLinkRef] = []
|
||||
for dep_id in task.dependency_ids or []:
|
||||
dep = await task_service.get(dep_id)
|
||||
if dep is not None:
|
||||
dependencies.append(TaskLinkRef(id=str(dep.id), title=dep.title))
|
||||
return tuple(dependencies)
|
||||
|
||||
|
||||
async def assemble_task_note_data(
|
||||
task_service: Any,
|
||||
project_service: Any,
|
||||
task: Any,
|
||||
*,
|
||||
narrative: str | None = None,
|
||||
) -> TaskNoteData:
|
||||
"""Build the full ``TaskNoteData`` for one task, resolving its parent,
|
||||
subtasks, dependencies, and project slug via the given services."""
|
||||
return TaskNoteData(
|
||||
id=str(task.id),
|
||||
title=task.title,
|
||||
project_slug=await _resolve_project_slug(project_service, task),
|
||||
description=task.description or "",
|
||||
status=_enum_value(task.status),
|
||||
team=_enum_value(task.team),
|
||||
priority=task.priority,
|
||||
task_type=_enum_value(task.task_type),
|
||||
acceptance_criteria=tuple(task.acceptance_criteria or ()),
|
||||
pr_number=task.pr_number,
|
||||
pr_url=task.pr_url,
|
||||
parent=await _resolve_parent(task_service, task),
|
||||
subtasks=await _resolve_subtasks(task_service, task),
|
||||
dependencies=await _resolve_dependencies(task_service, task),
|
||||
batch_id=str(task.batch_id) if task.batch_id else None,
|
||||
narrative=narrative,
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
"""VaultIntakeEngine — vault notes tagged ``#roboco`` become board-review drafts.
|
||||
|
||||
The vexa-inspired input loop (V1 item 4). Detection/dedup mirrors the
|
||||
XEngine mentions poll; the draft itself takes the intake **board-review
|
||||
path** (exactly what a chat-confirmed "Board review & Start" draft is), not
|
||||
the held-artifact pattern:
|
||||
|
||||
* **Default OFF.** Both ``obsidian_vault_enabled`` AND ``vault_intake_enabled``
|
||||
must be on — the engine is inert if either is off.
|
||||
* **Never starts work.** Every draft is a PENDING, Product-Owner-assigned,
|
||||
``team=board`` task (``source=vault_note``): the PM dispatcher routes it
|
||||
to the two-reviewer board review (PO + Head of Marketing), which ends in a
|
||||
CEO notification — nothing enters delivery until the CEO's explicit
|
||||
``approve_and_start`` hands it to the Main PM. Board dispatch only ever
|
||||
spawns the advisory REVIEW; board roles have no verb to claim, plan, or
|
||||
delegate, so the CEO gate is structural.
|
||||
* **Local model only.** Extraction runs on the local LLM (MemoryDistiller
|
||||
posture) with a deterministic fallback (first heading / raw body /
|
||||
checkbox lines) on any failure — never a cloud LLM in the hot path. The
|
||||
raw note body reaches the local model unsanitized — the same documented
|
||||
prompt-injection surface x_engine accepts for mention text; the board +
|
||||
CEO gates downstream are the containment, not the prompt.
|
||||
* **Dedup ledger.** ``vault_seen_notes`` keys on (vault-relative path,
|
||||
content hash) so an unchanged note is never reprocessed, but an edited one
|
||||
is eligible again. The hash excludes RoboCo's own feedback callout so
|
||||
appending it after processing does not itself trigger a reprocess.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import VaultSeenNoteTable
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.task import VAULT_NOTE_SOURCE, TaskCreateRequest, get_task_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import ProjectTable, TaskTable
|
||||
|
||||
_CHAT_TIMEOUT_SECONDS = 60.0
|
||||
_AC_MAX_ITEMS = 7
|
||||
_AC_MAX_ITEM_CHARS = 200
|
||||
_TITLE_MAX_CHARS = 200
|
||||
_DEFAULT_AC = "CEO reviews and starts this drafted task"
|
||||
|
||||
# Frontmatter block at the start of the file (mirrors vault_writer's own
|
||||
# helper — a local copy, since inbox notes are arbitrary CEO-authored
|
||||
# markdown, a different trust/shape boundary than the projection core's own
|
||||
# generated notes).
|
||||
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
|
||||
# A whole `#roboco` tag, not a prefix of a longer tag (`#roboco/idea`) or word.
|
||||
_INLINE_TAG_RE = re.compile(r"(?<![\w/-])#roboco(?![\w/-])")
|
||||
_HEADING_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$", re.MULTILINE)
|
||||
_CHECKBOX_RE = re.compile(r"^\s*-\s*\[ \]\s*(.+?)\s*$", re.MULTILINE)
|
||||
# The feedback callout this engine appends (see _append_feedback_callout).
|
||||
# Stripped before hashing so appending it doesn't change the ledger key.
|
||||
_FEEDBACK_CALLOUT_RE = re.compile(r"\n?> \[!info\] RoboCo: drafted .*(?:\n|$)")
|
||||
|
||||
|
||||
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""Frontmatter dict + body, or ({}, text) with no frontmatter block."""
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}, text
|
||||
loaded = yaml.safe_load(m.group(1))
|
||||
return (loaded if isinstance(loaded, dict) else {}), text[m.end() :]
|
||||
|
||||
|
||||
def _has_roboco_tag(frontmatter: dict[str, Any], body: str) -> bool:
|
||||
"""True if frontmatter ``tags`` (list or scalar) or the body carries a
|
||||
whole ``#roboco`` tag."""
|
||||
tags = frontmatter.get("tags")
|
||||
if isinstance(tags, str):
|
||||
tags = [tags]
|
||||
if isinstance(tags, list) and any(
|
||||
str(t).strip().lstrip("#") == "roboco" for t in tags
|
||||
):
|
||||
return True
|
||||
return bool(_INLINE_TAG_RE.search(body))
|
||||
|
||||
|
||||
def _content_hash(raw_text: str) -> str:
|
||||
"""Sha256 of the note with RoboCo's own feedback callout stripped out."""
|
||||
stable = _FEEDBACK_CALLOUT_RE.sub("", raw_text)
|
||||
return hashlib.sha256(stable.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _clamp_action_items(items: list[str]) -> list[str]:
|
||||
cleaned = [i.strip()[:_AC_MAX_ITEM_CHARS] for i in items if i.strip()]
|
||||
return cleaned[:_AC_MAX_ITEMS]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _NoteExtraction:
|
||||
"""title + description + action items pulled from one vault note —
|
||||
bundled so ``_originate`` doesn't need one param per field."""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
action_items: list[str]
|
||||
|
||||
|
||||
def _deterministic_extract(body: str, fallback_title: str) -> _NoteExtraction:
|
||||
"""Local-model-failure fallback: first heading, raw body, checkbox lines."""
|
||||
heading = _HEADING_RE.search(body)
|
||||
title = heading.group(1).strip() if heading else fallback_title
|
||||
action_items = [m.group(1).strip() for m in _CHECKBOX_RE.finditer(body)]
|
||||
description = body.strip() or f"Vault note: {title}"
|
||||
return _NoteExtraction(title, description, action_items)
|
||||
|
||||
|
||||
def _extraction_prompt(body: str) -> str:
|
||||
return (
|
||||
"Extract a task draft from this Obsidian vault note. Reply with ONLY "
|
||||
'a JSON object: {"title": <short title>, "description": <1-3 '
|
||||
'sentence summary>, "action_items": [<action item>, ...]}. No '
|
||||
"markdown fences, no commentary.\n\n"
|
||||
f"Note:\n{body.strip()}\n"
|
||||
)
|
||||
|
||||
|
||||
async def _chat(prompt: str) -> str | None:
|
||||
"""One local-LLM chat call (OpenAI-compatible); None on a non-success."""
|
||||
async with httpx.AsyncClient(timeout=_CHAT_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.local_llm_base_url}/chat/completions",
|
||||
json={
|
||||
"model": settings.local_llm_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 400,
|
||||
},
|
||||
)
|
||||
if not resp.is_success:
|
||||
return None
|
||||
data = resp.json()
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
return None
|
||||
content = choices[0].get("message", {}).get("content")
|
||||
return content if isinstance(content, str) else None
|
||||
|
||||
|
||||
def _parse_extraction(raw: str) -> _NoteExtraction | None:
|
||||
"""Parse the local model's JSON reply; None on any malformed shape."""
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
title = str(data.get("title") or "").strip()
|
||||
description = str(data.get("description") or "").strip()
|
||||
if not title or not description:
|
||||
return None
|
||||
raw_items = data.get("action_items")
|
||||
items = [str(i) for i in raw_items] if isinstance(raw_items, list) else []
|
||||
return _NoteExtraction(title, description, items)
|
||||
|
||||
|
||||
class VaultIntakeEngine(BaseService):
|
||||
"""Turn ``#roboco``-tagged vault notes into board-review intake drafts."""
|
||||
|
||||
service_name = "vault_intake_engine"
|
||||
|
||||
async def run_cycle(self) -> list[TaskTable]:
|
||||
"""One intake pass: scan, dedup, extract, draft for board review.
|
||||
Empty list unless both the vault AND intake flags are on, the intake
|
||||
dir exists, the open-draft cap isn't already reached, and the RoboCo
|
||||
project resolves."""
|
||||
if not (settings.obsidian_vault_enabled and settings.vault_intake_enabled):
|
||||
return []
|
||||
intake_dir = Path(settings.vault_path) / settings.vault_intake_dir
|
||||
if not intake_dir.is_dir():
|
||||
return []
|
||||
task_svc = get_task_service(self.session)
|
||||
open_count = len(await task_svc.list_open_vault_note_drafts())
|
||||
if open_count >= settings.vault_intake_max_open_drafts:
|
||||
self.log.info("vault-intake: open-draft cap reached; skipping cycle")
|
||||
return []
|
||||
project = await self._roboco_project()
|
||||
if project is None or project.id is None:
|
||||
self.log.warning("vault-intake: RoboCo project not resolvable; skipping")
|
||||
return []
|
||||
return await self._process_notes(
|
||||
sorted(intake_dir.glob("*.md")), cast("UUID", project.id), open_count
|
||||
)
|
||||
|
||||
async def _roboco_project(self) -> ProjectTable | None:
|
||||
slug = (settings.self_heal_project_slug or "roboco-api").strip()
|
||||
return await get_project_service(self.session).get_by_slug(slug)
|
||||
|
||||
async def _process_notes(
|
||||
self, note_paths: list[Path], project_id: UUID, open_count: int
|
||||
) -> list[TaskTable]:
|
||||
"""Per-note isolation: one bad note (unreadable, malformed) is
|
||||
skipped and logged rather than aborting the rest of the cycle."""
|
||||
originated: list[TaskTable] = []
|
||||
for note_path in note_paths:
|
||||
if len(originated) >= settings.vault_intake_max_per_cycle:
|
||||
break
|
||||
if open_count + len(originated) >= settings.vault_intake_max_open_drafts:
|
||||
break
|
||||
try:
|
||||
task = await self._process_note(note_path, project_id)
|
||||
except OSError as exc:
|
||||
self.log.warning(
|
||||
"vault-intake: note read failed (skipped)",
|
||||
path=str(note_path),
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
if task is not None:
|
||||
originated.append(task)
|
||||
return originated
|
||||
|
||||
async def _process_note(
|
||||
self, note_path: Path, project_id: UUID
|
||||
) -> TaskTable | None:
|
||||
raw = note_path.read_text(encoding="utf-8")
|
||||
frontmatter, body = _split_frontmatter(raw)
|
||||
if not _has_roboco_tag(frontmatter, body):
|
||||
return None
|
||||
rel_path = str(note_path.relative_to(Path(settings.vault_path)))
|
||||
content_hash = _content_hash(raw)
|
||||
if await self._already_seen(rel_path, content_hash):
|
||||
return None
|
||||
extraction = await self._extract(body, note_path.stem)
|
||||
extraction = _NoteExtraction(
|
||||
extraction.title,
|
||||
extraction.description,
|
||||
_clamp_action_items(extraction.action_items),
|
||||
)
|
||||
task = await self._originate(
|
||||
project_id=project_id,
|
||||
rel_path=rel_path,
|
||||
content_hash=content_hash,
|
||||
extraction=extraction,
|
||||
)
|
||||
self.session.add(
|
||||
VaultSeenNoteTable(note_path=rel_path, content_hash=content_hash)
|
||||
)
|
||||
await self.session.flush()
|
||||
self._append_feedback_callout(note_path, task)
|
||||
return task
|
||||
|
||||
async def _extract(self, body: str, fallback_title: str) -> _NoteExtraction:
|
||||
try:
|
||||
raw = await _chat(_extraction_prompt(body))
|
||||
except Exception as exc:
|
||||
self.log.warning(
|
||||
"vault-intake: local-model extraction failed (fallback)",
|
||||
error=str(exc),
|
||||
)
|
||||
raw = None
|
||||
parsed = _parse_extraction(raw) if raw else None
|
||||
return parsed or _deterministic_extract(body, fallback_title)
|
||||
|
||||
async def _already_seen(self, rel_path: str, content_hash: str) -> bool:
|
||||
result = await self.session.execute(
|
||||
select(VaultSeenNoteTable.id)
|
||||
.where(
|
||||
VaultSeenNoteTable.note_path == rel_path,
|
||||
VaultSeenNoteTable.content_hash == content_hash,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def _originate(
|
||||
self,
|
||||
*,
|
||||
project_id: UUID,
|
||||
rel_path: str,
|
||||
content_hash: str,
|
||||
extraction: _NoteExtraction,
|
||||
) -> TaskTable:
|
||||
"""Open ONE PENDING board-review draft (the intake "Board review &
|
||||
Start" shape: Product-Owner-assigned, ``team=board``). The board
|
||||
reviews it; delivery starts only at the CEO's ``approve_and_start``,
|
||||
which flips team to MAIN_PM and hands it to the Main PM.
|
||||
``confirmed_by_human=True`` matches the intake path — the board
|
||||
routing is the start gate, not the confirm flag. PLANNING-typed: the
|
||||
post-approve owner is the Main PM, which never owns code (the type
|
||||
``approve_and_start`` would coerce to anyway)."""
|
||||
task_svc = get_task_service(self.session)
|
||||
task = await task_svc.create(
|
||||
TaskCreateRequest(
|
||||
title=f"Vault note: {extraction.title}"[:_TITLE_MAX_CHARS],
|
||||
description=extraction.description,
|
||||
acceptance_criteria=extraction.action_items or [_DEFAULT_AC],
|
||||
team=Team.BOARD,
|
||||
assigned_to=_foundation.AGENTS["product-owner"].uuid,
|
||||
created_by=_foundation.AGENTS["system"].uuid,
|
||||
task_type=TaskType.PLANNING,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
estimated_complexity=Complexity.MEDIUM,
|
||||
project_id=project_id,
|
||||
status=TaskStatus.PENDING,
|
||||
source=VAULT_NOTE_SOURCE,
|
||||
confirmed_by_human=True, # board-routed; approve_and_start is the gate
|
||||
)
|
||||
)
|
||||
markers.set_vault_note_ref(
|
||||
task,
|
||||
{
|
||||
"path": rel_path,
|
||||
"content_hash": content_hash,
|
||||
"action_items": extraction.action_items,
|
||||
},
|
||||
)
|
||||
await self.session.flush()
|
||||
self.log.info(
|
||||
"vault-intake: board-review draft opened",
|
||||
task_id=str(task.id),
|
||||
path=rel_path,
|
||||
)
|
||||
return task
|
||||
|
||||
def _append_feedback_callout(self, note_path: Path, task: TaskTable) -> None:
|
||||
"""Best-effort: a filesystem error here never fails the cycle."""
|
||||
try:
|
||||
id8 = str(task.id)[:8]
|
||||
date_str = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
line = f"\n> [!info] RoboCo: drafted {task.title} ({id8}) on {date_str}\n"
|
||||
with note_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(line)
|
||||
except OSError as exc:
|
||||
self.log.warning(
|
||||
"vault-intake: feedback callout append failed",
|
||||
path=str(note_path),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def get_vault_intake_engine(session: AsyncSession) -> VaultIntakeEngine:
|
||||
"""Build a VaultIntakeEngine for ``session``."""
|
||||
return VaultIntakeEngine(session)
|
||||
@@ -0,0 +1,370 @@
|
||||
"""VaultWriter — pure Obsidian-vault materializer (projection core, V1).
|
||||
|
||||
Entity -> markdown note (frontmatter + body). Idempotent per entity id: the
|
||||
same input always yields the same file, and safe to re-run (rebuild/CLI,
|
||||
retried seams). No DB/network access — callers assemble plain dataclasses
|
||||
from their own service layer and hand them to this module.
|
||||
|
||||
Layout (``docs/internal/specs/2026-07-09-obsidian-vault.md`` §Vault layout)::
|
||||
|
||||
RoboCo/
|
||||
Tasks/<project-slug>/<title> (<id8>).md
|
||||
Journals/<agent-slug>/<date> <title> (<id8>).md
|
||||
A2A/<date> <agents> (<thread-id8>).md
|
||||
Agents/<slug>.md
|
||||
_meta/
|
||||
|
||||
Link stability: every note carries ``aliases: [<id8>]`` in frontmatter, so a
|
||||
cross-link is always written as ``[[<id8>|<title>]]`` — Obsidian resolves it
|
||||
via the alias regardless of the target's CURRENT filename. A title edit
|
||||
therefore updates the body's title line (and, for a full ``write_task``
|
||||
re-render, the frontmatter) without ever renaming the file or breaking a
|
||||
link elsewhere in the vault.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
_ILLEGAL_FILENAME_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
_MAX_TITLE_LEN = 80
|
||||
_NARRATIVE_PLACEHOLDER = "_Pending Auditor curation._"
|
||||
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL)
|
||||
|
||||
|
||||
def _safe_title(title: str) -> str:
|
||||
"""Filesystem-safe, length-capped title for a filename component."""
|
||||
cleaned = _ILLEGAL_FILENAME_CHARS.sub("", title or "").strip()
|
||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||
return (cleaned or "untitled")[:_MAX_TITLE_LEN]
|
||||
|
||||
|
||||
def _id8(entity_id: str) -> str:
|
||||
return str(entity_id)[:8]
|
||||
|
||||
|
||||
def _find_by_id8(directory: Path, id8: str) -> Path | None:
|
||||
"""Locate an existing note by its stable id8 suffix, if the dir exists."""
|
||||
if not directory.exists():
|
||||
return None
|
||||
matches = sorted(directory.glob(f"*({id8}).md"))
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def _rfind_by_id8(root: Path, id8: str) -> Path | None:
|
||||
"""Recursive variant of ``_find_by_id8`` for callers that don't know
|
||||
which subfolder (e.g. project slug) a note lives under."""
|
||||
if not root.exists():
|
||||
return None
|
||||
matches = sorted(root.rglob(f"*({id8}).md"))
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def _drop_none(mapping: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in mapping.items() if v is not None}
|
||||
|
||||
|
||||
def _render_note(frontmatter: dict[str, Any], body: str) -> str:
|
||||
fm = yaml.safe_dump(
|
||||
_drop_none(frontmatter), sort_keys=False, default_flow_style=False
|
||||
).strip()
|
||||
return f"---\n{fm}\n---\n\n{body.rstrip()}\n"
|
||||
|
||||
|
||||
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}, text
|
||||
loaded = yaml.safe_load(m.group(1))
|
||||
fm = loaded if isinstance(loaded, dict) else {}
|
||||
return fm, text[m.end() :]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLinkRef:
|
||||
"""A wikilink target: id (any length; truncated to id8) + display title."""
|
||||
|
||||
id: str
|
||||
title: str = ""
|
||||
|
||||
|
||||
def _wikilink(ref: TaskLinkRef) -> str:
|
||||
id8 = _id8(ref.id)
|
||||
return f"[[{id8}|{ref.title}]]" if ref.title else f"[[{id8}]]"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskNoteData:
|
||||
"""Deterministic content for one task note. ``narrative`` is None until
|
||||
the Auditor curates it (a placeholder is rendered instead)."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
project_slug: str
|
||||
description: str
|
||||
status: str
|
||||
team: str
|
||||
priority: int
|
||||
task_type: str
|
||||
acceptance_criteria: tuple[str, ...] = ()
|
||||
pr_number: int | None = None
|
||||
pr_url: str | None = None
|
||||
parent: TaskLinkRef | None = None
|
||||
subtasks: tuple[TaskLinkRef, ...] = ()
|
||||
dependencies: tuple[TaskLinkRef, ...] = ()
|
||||
batch_id: str | None = None
|
||||
narrative: str | None = None
|
||||
|
||||
|
||||
def _task_frontmatter(data: TaskNoteData, id8: str) -> dict[str, Any]:
|
||||
return {
|
||||
"aliases": [id8],
|
||||
"status": data.status,
|
||||
"team": data.team,
|
||||
"priority": data.priority,
|
||||
"pr": data.pr_url or (f"#{data.pr_number}" if data.pr_number else None),
|
||||
"parent": f"[[{_id8(data.parent.id)}]]" if data.parent else None,
|
||||
"batch": _id8(data.batch_id) if data.batch_id else None,
|
||||
"tags": [f"status/{data.status}", f"team/{data.team}"],
|
||||
}
|
||||
|
||||
|
||||
def _task_body(data: TaskNoteData) -> list[str]:
|
||||
body: list[str] = [f"# {data.title}", "", (data.description or "").strip()]
|
||||
if data.acceptance_criteria:
|
||||
body += ["", "## Acceptance Criteria"]
|
||||
body += [f"- [ ] {c}" for c in data.acceptance_criteria]
|
||||
if data.parent:
|
||||
body += ["", "## Parent", f"- {_wikilink(data.parent)}"]
|
||||
if data.subtasks:
|
||||
body += ["", "## Subtasks"]
|
||||
body += [f"- {_wikilink(s)}" for s in data.subtasks]
|
||||
if data.dependencies:
|
||||
body += ["", "## Dependencies"]
|
||||
body += [f"- {_wikilink(d)}" for d in data.dependencies]
|
||||
body += ["", "## Narrative", (data.narrative or _NARRATIVE_PLACEHOLDER).strip()]
|
||||
return body
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JournalNoteData:
|
||||
entry_id: str
|
||||
agent_slug: str
|
||||
scope: str
|
||||
title: str
|
||||
content: str
|
||||
timestamp: datetime
|
||||
task_ref: TaskLinkRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class A2AMessageData:
|
||||
conversation_id: str
|
||||
message_id: str
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
content: str
|
||||
timestamp: datetime
|
||||
task_ref: TaskLinkRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentNoteData:
|
||||
slug: str
|
||||
name: str
|
||||
role: str
|
||||
team: str | None = None
|
||||
|
||||
|
||||
class VaultWriter:
|
||||
"""Pure file-system materializer, rooted at ``root`` (``ROBOCO_VAULT_PATH``)."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = Path(root)
|
||||
|
||||
# --- paths ---------------------------------------------------------- #
|
||||
|
||||
def _tasks_root(self) -> Path:
|
||||
return self.root / "RoboCo" / "Tasks"
|
||||
|
||||
def _journals_root(self) -> Path:
|
||||
return self.root / "RoboCo" / "Journals"
|
||||
|
||||
def _a2a_root(self) -> Path:
|
||||
return self.root / "RoboCo" / "A2A"
|
||||
|
||||
def _agents_root(self) -> Path:
|
||||
return self.root / "RoboCo" / "Agents"
|
||||
|
||||
# --- tasks ------------------------------------------------------------ #
|
||||
|
||||
def write_task(self, data: TaskNoteData) -> Path:
|
||||
"""Full deterministic materialize (create-or-overwrite). The
|
||||
filename is stable across title renames — an existing note is found
|
||||
by id8 and its filename reused; only a brand-new note is named from
|
||||
the current title."""
|
||||
id8 = _id8(data.id)
|
||||
directory = self._tasks_root() / (data.project_slug or "unassigned")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
existing = _find_by_id8(directory, id8)
|
||||
filename = (
|
||||
existing.name if existing else f"{_safe_title(data.title)} ({id8}).md"
|
||||
)
|
||||
path = directory / filename
|
||||
path.write_text(
|
||||
_render_note(_task_frontmatter(data, id8), "\n".join(_task_body(data))),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
def existing_narrative(self, project_slug: str, task_id: str) -> str | None:
|
||||
"""Read back an existing note's ``## Narrative`` section so a rebuild
|
||||
never clobbers Auditor-authored prose (it isn't derivable from DB
|
||||
state). None when the note doesn't exist yet or still carries the
|
||||
deterministic placeholder."""
|
||||
directory = self._tasks_root() / (project_slug or "unassigned")
|
||||
existing = _find_by_id8(directory, _id8(task_id))
|
||||
if existing is None:
|
||||
return None
|
||||
_, body = _split_frontmatter(existing.read_text(encoding="utf-8"))
|
||||
marker = "## Narrative"
|
||||
idx = body.find(marker)
|
||||
if idx == -1:
|
||||
return None
|
||||
narrative = body[idx + len(marker) :].strip()
|
||||
return narrative if narrative and narrative != _NARRATIVE_PLACEHOLDER else None
|
||||
|
||||
def touch_task_frontmatter(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
status: str,
|
||||
team: str,
|
||||
pr_number: int | None,
|
||||
pr_url: str | None,
|
||||
) -> bool:
|
||||
"""Cheap status-transition touch: patch frontmatter keys in place on
|
||||
an EXISTING note, never rewriting the body/links. No-op (returns
|
||||
False) when the note hasn't been materialized yet — full
|
||||
materialization happens at Auditor curation / CLI rebuild, per the
|
||||
vault's event-driven freshness model, so this never invents content
|
||||
it doesn't have (no extra queries — just the fields on the row)."""
|
||||
id8 = _id8(task_id)
|
||||
path = _rfind_by_id8(self._tasks_root(), id8)
|
||||
if path is None:
|
||||
return False
|
||||
fm, body = _split_frontmatter(path.read_text(encoding="utf-8"))
|
||||
fm["status"] = status
|
||||
fm["team"] = team
|
||||
fm["pr"] = pr_url or (f"#{pr_number}" if pr_number else None)
|
||||
fm["tags"] = [f"status/{status}", f"team/{team}"]
|
||||
path.write_text(_render_note(fm, body), encoding="utf-8")
|
||||
return True
|
||||
|
||||
# --- journals ----------------------------------------------------------- #
|
||||
|
||||
def write_journal_entry(self, data: JournalNoteData) -> Path:
|
||||
"""One file per entry (immutable once written — always safe to
|
||||
re-render the same entry id in place)."""
|
||||
directory = self._journals_root() / data.agent_slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
id8 = _id8(data.entry_id)
|
||||
date_str = data.timestamp.strftime("%Y-%m-%d")
|
||||
existing = _find_by_id8(directory, id8)
|
||||
filename = (
|
||||
existing.name
|
||||
if existing
|
||||
else f"{date_str} {_safe_title(data.title)} ({id8}).md"
|
||||
)
|
||||
path = directory / filename
|
||||
|
||||
frontmatter = {
|
||||
"aliases": [id8],
|
||||
"agent": data.agent_slug,
|
||||
"scope": data.scope,
|
||||
"date": date_str,
|
||||
}
|
||||
body = [f"# {data.title}", ""]
|
||||
if data.task_ref:
|
||||
body += [f"Task: {_wikilink(data.task_ref)}", ""]
|
||||
body.append((data.content or "").strip())
|
||||
|
||||
path.write_text(_render_note(frontmatter, "\n".join(body)), encoding="utf-8")
|
||||
return path
|
||||
|
||||
# --- A2A ---------------------------------------------------------------- #
|
||||
|
||||
def append_a2a_message(self, data: A2AMessageData) -> Path:
|
||||
"""Per-thread digest file, appended to on every message. Idempotent
|
||||
per message id (a marker comment guards against a double-append on
|
||||
retry)."""
|
||||
directory = self._a2a_root()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
id8 = _id8(data.conversation_id)
|
||||
participants = sorted({data.from_agent, data.to_agent})
|
||||
existing = _find_by_id8(directory, id8)
|
||||
if existing is None:
|
||||
date_str = data.timestamp.strftime("%Y-%m-%d")
|
||||
label = "-".join(participants)
|
||||
filename = f"{date_str} {_safe_title(label)} ({id8}).md"
|
||||
path = directory / filename
|
||||
frontmatter = {
|
||||
"aliases": [id8],
|
||||
"participants": participants,
|
||||
"task": _wikilink(data.task_ref) if data.task_ref else None,
|
||||
}
|
||||
header = [
|
||||
f"# A2A: {label}",
|
||||
"",
|
||||
"Participants: " + ", ".join(f"[[{p}]]" for p in participants),
|
||||
]
|
||||
if data.task_ref:
|
||||
header += [f"Task: {_wikilink(data.task_ref)}"]
|
||||
path.write_text(
|
||||
_render_note(frontmatter, "\n".join(header)), encoding="utf-8"
|
||||
)
|
||||
else:
|
||||
path = existing
|
||||
|
||||
marker = f"<!-- msg:{data.message_id} -->"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if marker in text:
|
||||
return path
|
||||
ts = data.timestamp.strftime("%H:%M:%S")
|
||||
block = (
|
||||
f"\n**{data.from_agent}** ({ts}) {marker}\n{(data.content or '').strip()}\n"
|
||||
)
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(block)
|
||||
return path
|
||||
|
||||
# --- agents --------------------------------------------------------------- #
|
||||
|
||||
def write_agent(self, data: AgentNoteData) -> Path:
|
||||
"""Identity hub note; backlinks (Obsidian-native) collect this
|
||||
agent's tasks/journal-entries/A2A threads — no explicit index kept
|
||||
here."""
|
||||
directory = self._agents_root()
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{data.slug}.md"
|
||||
frontmatter = {"role": data.role, "team": data.team}
|
||||
body = f"# {data.name}\n\nRole: {data.role}\nTeam: {data.team or '(none)'}\n"
|
||||
path.write_text(_render_note(frontmatter, body), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def get_vault_writer() -> VaultWriter:
|
||||
"""Factory reading ``settings.vault_path``. Callers gate on
|
||||
``settings.obsidian_vault_enabled`` themselves (this module has no
|
||||
opinion on the flag — it's a pure materializer)."""
|
||||
from roboco.config import settings
|
||||
|
||||
return VaultWriter(Path(settings.vault_path))
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
"""``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.
|
||||
|
||||
``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 assemble_task_note_data
|
||||
|
||||
offset = 0
|
||||
while True:
|
||||
tasks = await task_service.list_all(limit=100, offset=offset)
|
||||
if not tasks:
|
||||
break
|
||||
for task in tasks:
|
||||
project_slug = "unassigned"
|
||||
if task.project_id is not None:
|
||||
project = await project_service.get(task.project_id)
|
||||
if project is not None:
|
||||
project_slug = project.slug
|
||||
narrative = writer.existing_narrative(project_slug, str(task.id))
|
||||
data = await assemble_task_note_data(
|
||||
task_service, project_service, task, narrative=narrative
|
||||
)
|
||||
writer.write_task(data)
|
||||
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())
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Packaged Obsidian-vault template assets (.obsidian/ config + _meta/ dashboards).
|
||||
|
||||
Static, non-Python files only. Copied into the live vault by
|
||||
``roboco.vault.ensure_vault_assets`` (first enable / rebuild); never imported
|
||||
as code. Kept as a real package (not a loose repo-root dir) so
|
||||
``importlib.resources`` resolves them regardless of how ``roboco`` is
|
||||
installed/packaged.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
# RoboCo vault
|
||||
|
||||
This vault is a projection of RoboCo's live task/journal/A2A state — read-only from the org's point of view (edits here never flow back into RoboCo; see `docs.roboco.tech` for the full model).
|
||||
|
||||
- **Tasks/** — one note per task, grouped by project. Frontmatter carries `status`/`team`/`priority`/`pr`/`parent`/`batch`; the `## Narrative` section is filled in by the Auditor once the task's root completes.
|
||||
- **Journals/** — one note per journal entry, grouped by agent.
|
||||
- **A2A/** — one digest note per agent-to-agent conversation thread.
|
||||
- **Agents/** — an identity hub per agent; Obsidian's backlinks panel collects everything that note links to it.
|
||||
- **_meta/** — this folder: dashboards and static "commands" (`dashboard.md` for Dataview, `kanban-board.md` for the Kanban plugin).
|
||||
|
||||
Regenerate at any time with `python -m roboco.vault rebuild` (safe — a task's Auditor-authored narrative is preserved across a rebuild).
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task board (Dataview)
|
||||
|
||||
Live queries over `RoboCo/Tasks/**` — requires the Dataview community plugin (already named in `.obsidian/community-plugins.json`; install it via Settings -> Community plugins if it isn't downloaded yet).
|
||||
|
||||
## Open work, by status
|
||||
|
||||
```dataview
|
||||
TABLE status, team, priority, pr AS "PR"
|
||||
FROM "RoboCo/Tasks"
|
||||
WHERE status != "completed" AND status != "cancelled"
|
||||
SORT priority ASC, status ASC
|
||||
```
|
||||
|
||||
## Blocked
|
||||
|
||||
```dataview
|
||||
TABLE team, pr AS "PR"
|
||||
FROM "RoboCo/Tasks"
|
||||
WHERE status = "blocked"
|
||||
SORT team ASC
|
||||
```
|
||||
|
||||
## Recently completed
|
||||
|
||||
```dataview
|
||||
TABLE team, pr AS "PR"
|
||||
FROM "RoboCo/Tasks"
|
||||
WHERE status = "completed"
|
||||
SORT file.mtime DESC
|
||||
LIMIT 20
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
kanban-plugin: basic
|
||||
---
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
|
||||
## In Progress
|
||||
|
||||
|
||||
|
||||
## Review
|
||||
|
||||
|
||||
|
||||
## Done
|
||||
|
||||
|
||||
|
||||
%% kanban:settings
|
||||
```
|
||||
{"kanban-plugin":"basic"}
|
||||
```
|
||||
%%
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"dataview",
|
||||
"obsidian-kanban"
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"collapse-filter": true,
|
||||
"search": "",
|
||||
"showTags": false,
|
||||
"showAttachments": false,
|
||||
"hideUnresolved": false,
|
||||
"showOrphans": true,
|
||||
"collapse-color-groups": false,
|
||||
"colorGroups": [
|
||||
{ "query": "tag:#team/backend", "color": { "a": 1, "rgb": 2455239 } },
|
||||
{ "query": "tag:#team/frontend", "color": { "a": 1, "rgb": 16738740 } },
|
||||
{ "query": "tag:#team/ux_ui", "color": { "a": 1, "rgb": 10181046 } },
|
||||
{ "query": "tag:#team/main_pm", "color": { "a": 1, "rgb": 16755763 } },
|
||||
{ "query": "tag:#team/board", "color": { "a": 1, "rgb": 4886754 } },
|
||||
{ "query": "tag:#status/completed", "color": { "a": 1, "rgb": 3381606 } },
|
||||
{ "query": "tag:#status/blocked", "color": { "a": 1, "rgb": 15277667 } },
|
||||
{ "query": "path:RoboCo/Journals", "color": { "a": 1, "rgb": 9807270 } },
|
||||
{ "query": "path:RoboCo/A2A", "color": { "a": 1, "rgb": 12634495 } },
|
||||
{ "query": "path:RoboCo/Agents", "color": { "a": 1, "rgb": 6250335 } }
|
||||
],
|
||||
"collapse-display": true,
|
||||
"showArrow": false,
|
||||
"textFadeMultiplier": 0,
|
||||
"nodeSizeMultiplier": 1,
|
||||
"lineSizeMultiplier": 1,
|
||||
"collapse-forces": true,
|
||||
"centerStrength": 0.5,
|
||||
"repelStrength": 10,
|
||||
"linkStrength": 1,
|
||||
"linkDistance": 250,
|
||||
"scale": 1,
|
||||
"close": false
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user