feat: devpulse dashboard plugin + startup protocol wiring (DPLAN-0180 Phases 2-6)

This commit is contained in:
AIOSAI
2026-05-16 23:22:52 -07:00
parent d59bbfc300
commit 716827b05d
9 changed files with 629 additions and 3 deletions
+3 -3
View File
@@ -6,11 +6,11 @@ User: user
# Startup protocol
On any greeting, silently read these files from CWD and run the commands — no narration, no announcing steps. Just do it and respond with the status.
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
- Check: `drone @ai_mail inbox` — process any mail, don't ask.
- Run: `drone @git status`
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
- Refresh: If `STATUS.local.md` is stale (last updated date older than latest session in local.json), update it from your memories. Keep Current Work accurate.
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
+5
View File
@@ -497,6 +497,11 @@
"file": "apps/__init__.py",
"reason": "Package marker \u2014 not a CLI entry point. --version is in apps/prax.py."
},
{
"file": "tests/test_devpulse_dashboard_plugin.py",
"standard": "architecture",
"reason": "Test file lives in tests/ by convention, not in the 3-layer apps/ structure. This is standard for all prax test files."
},
{
"file": "tests/test_logging.py",
"standard": "architecture",
+9
View File
@@ -0,0 +1,9 @@
# =================== AIPass ====================
# Name: __init__.py
# Description: Prax plugins directory
# Version: 1.0.0
# Created: 2026-05-16
# Modified: 2026-05-16
# =============================================
"""Prax plugins — extension point for cross-branch dashboard contributions."""
@@ -0,0 +1,20 @@
# =================== AIPass ====================
# Name: __init__.py
# Description: Devpulse dashboard plugin for prax
# Version: 1.0.0
# Created: 2026-05-16
# Modified: 2026-05-16
# =============================================
"""Devpulse dashboard plugin.
Populates devpulse's DASHBOARD.local.json with custom sections:
git, session, dispatch (and optionally system_health).
Each section builder gathers data and calls write_section() to persist.
refresh() orchestrates all section builders in sequence.
"""
from .refresh import refresh
__all__ = ["refresh"]
@@ -0,0 +1,85 @@
# =================== AIPass ====================
# Name: dispatch_section.py
# Description: Dispatch status section builder for devpulse dashboard
# Version: 1.0.0
# Created: 2026-05-16
# Modified: 2026-05-16
# =============================================
"""Dispatch section builder for devpulse dashboard plugin.
Scans all 12 AIPass branches for .dispatch.lock files to show
which agents are currently active. Writes to dashboard via write_section().
"""
import json
from pathlib import Path
from typing import Dict, List
from aipass.prax.apps.modules.dashboard import write_section
from aipass.prax.apps.modules.logger import system_logger as logger
BRANCH_NAMES: List[str] = [
"ai_mail",
"aipass",
"api",
"cli",
"devpulse",
"drone",
"flow",
"memory",
"prax",
"seedgo",
"spawn",
"trigger",
]
def _get_aipass_src(branch_path: Path) -> Path:
"""Resolve the src/aipass/ root from any branch path."""
return branch_path.resolve().parent
def _read_lock(lock_path: Path) -> Dict:
"""Read dispatch lock file, return parsed data or empty dict."""
try:
data = json.loads(lock_path.read_text())
return {
"subject": data.get("subject", ""),
"started": data.get("started", ""),
}
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to read dispatch lock %s: %s", lock_path, e)
return {"subject": "unknown", "started": ""}
def build_dispatch_section(branch_path: Path) -> bool:
"""Build dispatch section data and write to dashboard.
Scans all 12 branches for active dispatch locks.
Args:
branch_path: Path to devpulse branch root.
Returns:
True if write_section succeeded, False otherwise.
"""
aipass_src = _get_aipass_src(branch_path)
agents_active: List[str] = []
details: Dict[str, Dict] = {}
for name in BRANCH_NAMES:
lock_path = aipass_src / name / ".ai_mail.local" / ".dispatch.lock"
if lock_path.exists():
agents_active.append(name)
details[name] = _read_lock(lock_path)
section_data: Dict = {
"managed_by": "devpulse",
"agents_active": agents_active,
"agents_active_count": len(agents_active),
"details": details,
}
return write_section(branch_path, "dispatch", section_data)
@@ -0,0 +1,90 @@
# =================== AIPass ====================
# Name: git_section.py
# Description: Git status section builder for devpulse dashboard
# Version: 1.0.0
# Created: 2026-05-16
# Modified: 2026-05-16
# =============================================
"""Git section builder for devpulse dashboard plugin.
Gathers current git state (branch, changes, commits ahead of main)
and writes to devpulse's DASHBOARD.local.json via write_section().
"""
from pathlib import Path
from typing import Dict
import subprocess
from aipass.prax.apps.modules.dashboard import write_section
from aipass.prax.apps.modules.logger import system_logger as logger
def _find_repo_root(start: Path) -> Path:
"""Walk up from start to find .git directory."""
current = start.resolve()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
raise FileNotFoundError("No .git found above " + str(start))
def _run_git(repo_root: Path, *args: str) -> str:
"""Run a git command and return stdout. Returns empty string on failure."""
try:
result = subprocess.run(
["git"] + list(args),
capture_output=True,
text=True,
cwd=str(repo_root),
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
return ""
except (subprocess.SubprocessError, OSError) as e:
logger.info(f"git command failed: {args} — {e}")
return ""
def build_git_section(branch_path: Path) -> bool:
"""Build git section data and write to dashboard.
Args:
branch_path: Path to devpulse branch root.
Returns:
True if write_section succeeded, False otherwise.
"""
repo_root = _find_repo_root(branch_path)
# Current branch
branch = _run_git(repo_root, "rev-parse", "--abbrev-ref", "HEAD") or "unknown"
# Files changed (porcelain counts all modified/untracked)
status_output = _run_git(repo_root, "status", "--porcelain")
files_changed = len(status_output.splitlines()) if status_output else 0
# Commits ahead of origin/main
ahead_str = _run_git(repo_root, "rev-list", "--count", "origin/main..HEAD")
try:
ahead_of_main = int(ahead_str)
except (ValueError, TypeError) as e:
logger.info(f"Could not parse ahead count '{ahead_str}': {e}")
ahead_of_main = 0
# Last commit message and date
last_commit_msg = _run_git(repo_root, "log", "-1", "--format=%s") or ""
last_commit_date = _run_git(repo_root, "log", "-1", "--format=%cs") or ""
section_data: Dict = {
"managed_by": "devpulse",
"branch": branch,
"files_changed": files_changed,
"ahead_of_main": ahead_of_main,
"last_commit_msg": last_commit_msg,
"last_commit_date": last_commit_date,
}
return write_section(branch_path, "git", section_data)
@@ -0,0 +1,72 @@
# =================== AIPass ====================
# Name: refresh.py
# Description: Orchestrates all devpulse custom dashboard sections
# Version: 1.0.0
# Created: 2026-05-16
# Modified: 2026-05-16
# =============================================
"""Refresh orchestrator for devpulse dashboard plugin.
Calls each section builder in sequence. Each builder gathers its own data
and writes to DASHBOARD.local.json via prax's write_section() API.
Failures in one section don't block others.
"""
from pathlib import Path
from typing import Dict, List
from aipass.prax.apps.modules.logger import system_logger as logger
# Devpulse branch path — resolved relative to this file's location
_AIPASS_SRC = Path(__file__).resolve().parents[4] # .../src/aipass/
DEVPULSE_PATH = _AIPASS_SRC / "devpulse"
def refresh(branch_path: Path = None) -> Dict:
"""Refresh all devpulse custom dashboard sections.
Args:
branch_path: Override path to devpulse branch root.
Defaults to auto-resolved path.
Returns:
Dict with results per section: {section_name: {"success": bool, "error": str|None}}
"""
target = branch_path or DEVPULSE_PATH
results: Dict[str, Dict] = {}
builders: List[tuple] = [
("git", _refresh_git),
("session", _refresh_session),
("dispatch", _refresh_dispatch),
]
for name, builder_fn in builders:
try:
builder_fn(target)
results[name] = {"success": True, "error": None}
except Exception as e:
logger.error("Failed to refresh section '%s': %s", name, e)
results[name] = {"success": False, "error": str(e)}
return results
def _refresh_git(branch_path: Path) -> None:
from .git_section import build_git_section
build_git_section(branch_path)
def _refresh_session(branch_path: Path) -> None:
from .session_section import build_session_section
build_session_section(branch_path)
def _refresh_dispatch(branch_path: Path) -> None:
from .dispatch_section import build_dispatch_section
build_dispatch_section(branch_path)
@@ -0,0 +1,86 @@
# =================== AIPass ====================
# Name: session_section.py
# Description: Session info section builder for devpulse dashboard
# Version: 1.0.0
# Created: 2026-05-16
# Modified: 2026-05-16
# =============================================
"""Session section builder for devpulse dashboard plugin.
Reads .trinity/local.json to extract current session ID, date,
active tasks, and last session summary. Writes to dashboard
via write_section().
"""
import json
from pathlib import Path
from typing import Dict
from aipass.prax.apps.modules.dashboard import write_section
from aipass.prax.apps.modules.logger import system_logger as logger
def build_session_section(branch_path: Path) -> bool:
"""Build session section data and write to dashboard.
Args:
branch_path: Path to devpulse branch root.
Returns:
True if write_section succeeded, False otherwise.
"""
local_json_path = branch_path / ".trinity" / "local.json"
if not local_json_path.exists():
section_data: Dict = {
"managed_by": "devpulse",
"current_session": "unknown",
"session_date": "",
"today_focus": "",
"active_tasks": [],
"last_session_summary": "",
}
return write_section(branch_path, "session", section_data)
try:
data = json.loads(local_json_path.read_text())
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Failed to read local.json at %s: %s", local_json_path, exc)
section_data = {
"managed_by": "devpulse",
"current_session": "error",
"session_date": "",
"today_focus": "",
"active_tasks": [],
"last_session_summary": "Failed to read local.json",
}
return write_section(branch_path, "session", section_data)
# Extract latest session (first in list = newest)
sessions = data.get("sessions", [])
current_session = ""
session_date = ""
last_session_summary = ""
if sessions:
latest = sessions[0]
current_session = latest.get("id", "")
session_date = latest.get("d", "")
last_session_summary = latest.get("sum", "")
# Extract active tasks
active_tasks_data = data.get("active_tasks", {})
today_focus = active_tasks_data.get("today_focus", "")
pending = active_tasks_data.get("pending", [])
section_data = {
"managed_by": "devpulse",
"current_session": current_session,
"session_date": session_date,
"today_focus": today_focus,
"active_tasks": pending,
"last_session_summary": last_session_summary,
}
return write_section(branch_path, "session", section_data)
@@ -0,0 +1,259 @@
# =================== AIPass ====================
# Name: test_devpulse_dashboard_plugin.py
# Description: Tests for devpulse dashboard plugin
# Version: 1.0.0
# Created: 2026-05-16
# Modified: 2026-05-16
# =============================================
"""Tests for devpulse dashboard plugin (git, session, dispatch sections + refresh)."""
import json
from unittest.mock import patch, MagicMock
import pytest
def _git_subprocess_responses(cmd, **kwargs):
"""Mock subprocess.run responses for git commands."""
result = MagicMock()
result.returncode = 0
args = cmd[1:] # strip 'git'
responses = {
("rev-parse", "--abbrev-ref", "HEAD"): "dev\n",
("status", "--porcelain"): " M file1.py\n M file2.py\n",
("rev-list", "--count", "origin/main..HEAD"): "5\n",
("log", "-1", "--format=%s"): "feat: add dashboard plugin\n",
("log", "-1", "--format=%cs"): "2026-05-16\n",
}
result.stdout = responses.get(tuple(args), "")
return result
@pytest.fixture
def branch_path(tmp_path):
"""Create a minimal branch directory structure."""
(tmp_path / "DASHBOARD.local.json").write_text(
json.dumps(
{
"_warning": "AUTO-GENERATED",
"branch": "DEVPULSE",
"last_updated": "",
"quick_status": {"action_required": False},
"sections": {},
}
)
)
return tmp_path
@pytest.fixture
def branch_with_trinity(branch_path):
"""Branch path with .trinity/local.json."""
trinity = branch_path / ".trinity"
trinity.mkdir()
local_data = {
"sessions": [{"id": "S162", "d": "2026-05-16", "sum": "Test session summary", "st": "+"}],
"active_tasks": {
"today_focus": "Testing the plugin",
"pending": ["Task 1", "Task 2"],
"recently_completed": [],
},
}
(trinity / "local.json").write_text(json.dumps(local_data))
return branch_path
@pytest.fixture
def branch_with_git(branch_path):
"""Branch path inside a fake git repo."""
(branch_path / ".git").mkdir()
return branch_path
class TestGitSection:
"""Tests for git_section.py."""
@patch("aipass.prax.apps.plugins.devpulse_dashboard.git_section.subprocess.run")
def test_build_git_section_success(self, mock_run, branch_with_git):
"""Test successful git section build with mocked subprocess."""
from aipass.prax.apps.plugins.devpulse_dashboard.git_section import build_git_section
mock_run.side_effect = _git_subprocess_responses
result = build_git_section(branch_with_git)
assert result is True
# Verify dashboard was written
dash = json.loads((branch_with_git / "DASHBOARD.local.json").read_text())
git = dash["sections"]["git"]
assert git["managed_by"] == "devpulse"
assert git["branch"] == "dev"
assert git["files_changed"] == 2
assert git["ahead_of_main"] == 5
assert git["last_commit_msg"] == "feat: add dashboard plugin"
assert git["last_commit_date"] == "2026-05-16"
@patch("aipass.prax.apps.plugins.devpulse_dashboard.git_section.subprocess.run")
def test_build_git_section_subprocess_failure(self, mock_run, branch_with_git):
"""Test git section handles subprocess failures gracefully."""
from aipass.prax.apps.plugins.devpulse_dashboard.git_section import build_git_section
mock_run.side_effect = OSError("git not found")
# Should not raise — handles errors internally
result = build_git_section(branch_with_git)
# Still writes section with empty/default values
assert result is True
@patch("aipass.prax.apps.plugins.devpulse_dashboard.git_section._find_repo_root")
def test_build_git_section_no_git_dir(self, mock_find_root, branch_path):
"""Test git section when no .git directory exists."""
from aipass.prax.apps.plugins.devpulse_dashboard.git_section import build_git_section
mock_find_root.side_effect = FileNotFoundError("No .git found above " + str(branch_path))
with pytest.raises(FileNotFoundError):
build_git_section(branch_path)
class TestSessionSection:
"""Tests for session_section.py."""
def test_build_session_section_success(self, branch_with_trinity):
"""Test session section reads local.json correctly."""
from aipass.prax.apps.plugins.devpulse_dashboard.session_section import build_session_section
result = build_session_section(branch_with_trinity)
assert result is True
dash = json.loads((branch_with_trinity / "DASHBOARD.local.json").read_text())
session = dash["sections"]["session"]
assert session["managed_by"] == "devpulse"
assert session["current_session"] == "S162"
assert session["session_date"] == "2026-05-16"
assert session["today_focus"] == "Testing the plugin"
assert session["active_tasks"] == ["Task 1", "Task 2"]
assert session["last_session_summary"] == "Test session summary"
def test_build_session_section_no_local_json(self, branch_path):
"""Test session section when local.json doesn't exist."""
from aipass.prax.apps.plugins.devpulse_dashboard.session_section import build_session_section
result = build_session_section(branch_path)
assert result is True
dash = json.loads((branch_path / "DASHBOARD.local.json").read_text())
session = dash["sections"]["session"]
assert session["current_session"] == "unknown"
def test_build_session_section_corrupt_json(self, branch_path):
"""Test session section handles corrupt local.json."""
from aipass.prax.apps.plugins.devpulse_dashboard.session_section import build_session_section
trinity = branch_path / ".trinity"
trinity.mkdir()
(trinity / "local.json").write_text("not valid json{{{")
result = build_session_section(branch_path)
assert result is True
dash = json.loads((branch_path / "DASHBOARD.local.json").read_text())
session = dash["sections"]["session"]
assert session["current_session"] == "error"
class TestDispatchSection:
"""Tests for dispatch_section.py."""
def test_build_dispatch_section_no_locks(self, branch_path):
"""Test dispatch section when no agents are active."""
from aipass.prax.apps.plugins.devpulse_dashboard.dispatch_section import build_dispatch_section
# Create sibling branch dirs (dispatch scans parent for all branches)
parent = branch_path.parent
for name in ["ai_mail", "prax", "seedgo"]:
(parent / name / ".ai_mail.local").mkdir(parents=True, exist_ok=True)
result = build_dispatch_section(branch_path)
assert result is True
dash = json.loads((branch_path / "DASHBOARD.local.json").read_text())
dispatch = dash["sections"]["dispatch"]
assert dispatch["managed_by"] == "devpulse"
assert dispatch["agents_active"] == []
assert dispatch["agents_active_count"] == 0
def test_build_dispatch_section_with_locks(self, branch_path):
"""Test dispatch section detects active dispatch locks."""
from aipass.prax.apps.plugins.devpulse_dashboard.dispatch_section import build_dispatch_section
parent = branch_path.parent
# Create lock for "prax"
prax_mail = parent / "prax" / ".ai_mail.local"
prax_mail.mkdir(parents=True, exist_ok=True)
lock_data = {"pid": 12345, "subject": "Build dashboard view", "started": "2026-05-16T14:00:00"}
(prax_mail / ".dispatch.lock").write_text(json.dumps(lock_data))
result = build_dispatch_section(branch_path)
assert result is True
dash = json.loads((branch_path / "DASHBOARD.local.json").read_text())
dispatch = dash["sections"]["dispatch"]
assert "prax" in dispatch["agents_active"]
assert dispatch["agents_active_count"] == 1
assert dispatch["details"]["prax"]["subject"] == "Build dashboard view"
def test_build_dispatch_section_corrupt_lock(self, branch_path):
"""Test dispatch section handles corrupt lock files."""
from aipass.prax.apps.plugins.devpulse_dashboard.dispatch_section import build_dispatch_section
parent = branch_path.parent
seedgo_mail = parent / "seedgo" / ".ai_mail.local"
seedgo_mail.mkdir(parents=True, exist_ok=True)
(seedgo_mail / ".dispatch.lock").write_text("broken json{{{")
result = build_dispatch_section(branch_path)
assert result is True
dash = json.loads((branch_path / "DASHBOARD.local.json").read_text())
dispatch = dash["sections"]["dispatch"]
assert "seedgo" in dispatch["agents_active"]
assert dispatch["details"]["seedgo"]["subject"] == "unknown"
class TestRefresh:
"""Tests for refresh.py orchestrator."""
@patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_git")
@patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_session")
@patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_dispatch")
def test_refresh_all_success(self, mock_dispatch, mock_session, mock_git, branch_path):
"""Test refresh orchestrator calls all builders."""
from aipass.prax.apps.plugins.devpulse_dashboard.refresh import refresh
results = refresh(branch_path)
assert results["git"]["success"] is True
assert results["session"]["success"] is True
assert results["dispatch"]["success"] is True
mock_git.assert_called_once_with(branch_path)
mock_session.assert_called_once_with(branch_path)
mock_dispatch.assert_called_once_with(branch_path)
@patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_git")
@patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_session")
@patch("aipass.prax.apps.plugins.devpulse_dashboard.refresh._refresh_dispatch")
def test_refresh_partial_failure(self, mock_dispatch, mock_session, mock_git, branch_path):
"""Test refresh continues when one section fails."""
from aipass.prax.apps.plugins.devpulse_dashboard.refresh import refresh
mock_git.side_effect = FileNotFoundError("No .git")
results = refresh(branch_path)
assert results["git"]["success"] is False
assert "No .git" in results["git"]["error"]
assert results["session"]["success"] is True
assert results["dispatch"]["success"] is True
def test_refresh_default_path(self):
"""Test refresh uses DEVPULSE_PATH by default."""
from aipass.prax.apps.plugins.devpulse_dashboard.refresh import DEVPULSE_PATH
assert DEVPULSE_PATH.name == "devpulse"
assert "src/aipass/devpulse" in str(DEVPULSE_PATH)