Archive completed tasks, add cleanup commands, self-documenting dashboard UI
CI / build (push) Has been cancelled

- Archive 79 completed framework-dev tasks from tasks/ -> tasks/complete/
- status.py: add --cleanup-done and --install-cleanup-schedule commands
- Add scripts/automaton-cleanup.sh for periodic task archiving
- Dashboard: rename 'Background' tab -> 'Agent', 'Cleanup' agent -> 'Completed Task Archiver', remove redundant group headers and pill badges, dim inactive agent placeholders
- .rules.md: add Self-Documenting UI Names rule
- New tests: test_cleanup_done.py, expanded test_app.py and test_task.py
This commit is contained in:
Lap Tran
2026-06-24 22:43:33 -04:00
parent e13513faaa
commit 4a2301b077
572 changed files with 856 additions and 101 deletions
+75
View File
@@ -242,3 +242,78 @@ class TestConfigEndpoint:
handler._send_error = lambda c, m: errors.append((c, m))
handler._handle_config_update()
assert errors[0][0] == 400
def test_serve_scheduled_returns_jobs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`/api/scheduled` returns jobs from `_list_scheduled_jobs`."""
import automaton.dashboard.ui.app as app_mod
class _FakeStatusMod:
def _list_scheduled_jobs(self=None, project=None):
return [
{"label": "com.automaton.cleanup", "kind": "cleanup", "name": "",
"status": "enabled", "next_run_seconds": 86400,
"stub_path": "/x/stub.sh", "runtime_state": None},
{"label": "com.automaton.loop.ci", "kind": "loop", "name": "ci",
"status": "disabled", "next_run_seconds": 60,
"stub_path": None, "runtime_state": {"status": "paused"},
"agent_type": "single"},
]
monkeypatch.setattr(app_mod, "_get_status_module", lambda: _FakeStatusMod())
handler = DashboardHandler.__new__(DashboardHandler)
handler.project_root = tmp_path
response = {}
handler._send_json = lambda d: response.update(d)
handler._serve_scheduled()
assert response["available"] is True
assert len(response["jobs"]) == 2
assert response["jobs"][0]["kind"] == "cleanup"
assert response["jobs"][1]["name"] == "ci"
assert response["jobs"][1]["agent_type"] == "single"
def test_serve_scheduled_unavailable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When status.py can't be loaded, `/api/scheduled` reports unavailable."""
import automaton.dashboard.ui.app as app_mod
monkeypatch.setattr(app_mod, "_get_status_module", lambda: None)
handler = DashboardHandler.__new__(DashboardHandler)
handler.project_root = tmp_path
response = {}
handler._send_json = lambda d: response.update(d)
handler._serve_scheduled()
assert response["available"] is False
assert response["jobs"] == []
def test_serve_scheduled_handles_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Exceptions from `_list_scheduled_jobs` surface as `error` in the response."""
import automaton.dashboard.ui.app as app_mod
class _FakeStatusMod:
def _list_scheduled_jobs(self=None, project=None):
raise RuntimeError("boom")
monkeypatch.setattr(app_mod, "_get_status_module", lambda: _FakeStatusMod())
handler = DashboardHandler.__new__(DashboardHandler)
handler.project_root = tmp_path
response = {}
handler._send_json = lambda d: response.update(d)
handler._serve_scheduled()
assert response["available"] is True
assert response["jobs"] == []
assert "boom" in response["error"]
def test_get_status_module_returns_none_when_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`_get_status_module` returns None when status.py isn't found in ~/.automaton."""
import automaton.dashboard.ui.app as app_mod
# Reset the cached module, and point HOME at an empty dir so
# `Path.home() / .automaton / scripts / status.py` doesn't exist.
monkeypatch.setattr(app_mod, "_status_module", None)
monkeypatch.setenv("HOME", str(tmp_path))
# pathlib.Path.home() respects $HOME on every platform.
result = app_mod._get_status_module()
assert result is None
+163
View File
@@ -0,0 +1,163 @@
"""Tests for the task cleanup process (--cleanup-done) and its scheduled install.
Covers:
- cmd_cleanup_done: age threshold (by .state mtime), move, dry-run, skip
(dest exists, untracked, non-complete, below threshold), --json summary.
- --install-cleanup-schedule: stub + launchd plist written to
AUTOMATON_LAUNCHAGENTS_DIR override (no host bleed).
- _all_task_dirs excludes the 'complete' container (so --audit/--list stay
clean once tasks are archived).
"""
import importlib.util
import json
import os
import subprocess
import sys
import time
from pathlib import Path
import pytest
_STATUS_PATH = Path.home() / ".automaton" / "scripts" / "status.py"
_spec = importlib.util.spec_from_file_location("st_cleanup", _STATUS_PATH)
st = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(st)
def _task_ns(days=None, dry_run=False, json_out=False, project=None):
class A:
pass
a = A()
a.days = days
a.dry_run = dry_run
a.json_output = json_out
a.project = project
return a
def _make_complete_task(project_root: Path, name: str, age_days: float = 0.0) -> Path:
task_dir = project_root / ".automaton" / "tasks" / name
task_dir.mkdir(parents=True)
(task_dir / ".state").write_text("complete\n")
(task_dir / "VERDICT.md").write_text("## Status: PASS\n")
if age_days:
when = time.time() - age_days * 86400.0
os.utime(task_dir / ".state", (when, when))
return task_dir
@pytest.fixture
def tmp_project(tmp_path):
(tmp_path / ".automaton" / "tasks").mkdir(parents=True)
return tmp_path
class TestCleanupDone:
def test_moves_old_complete_task(self, tmp_project):
_make_complete_task(tmp_project, "old-task", age_days=8.0)
rc = st.cmd_cleanup_done(_task_ns(days=7, project=str(tmp_project)))
assert rc == 0
dest = tmp_project / ".automaton" / "tasks" / "complete" / "old-task"
assert dest.exists()
assert (dest / ".state").exists()
def test_keeps_recent_complete_task(self, tmp_project):
td = _make_complete_task(tmp_project, "fresh-task", age_days=1.0)
st.cmd_cleanup_done(_task_ns(days=7, project=str(tmp_project)))
assert td.exists() # not moved
assert not (tmp_project / ".automaton" / "tasks" / "complete" / "fresh-task").exists()
def test_dry_run_does_not_move(self, tmp_project):
td = _make_complete_task(tmp_project, "dry-task", age_days=10.0)
rc = st.cmd_cleanup_done(_task_ns(days=7, dry_run=True, project=str(tmp_project)))
assert rc == 0
assert td.exists() # still in place
assert not (tmp_project / ".automaton" / "tasks" / "complete").exists()
def test_skip_untracked(self, tmp_project):
# dir exists but no .state -> untracked, ignored
untracked = tmp_project / ".automaton" / "tasks" / "no-state"
untracked.mkdir(parents=True)
rc = st.cmd_cleanup_done(_task_ns(days=0, project=str(tmp_project)))
assert rc == 0
assert untracked.exists() # untouched
def test_skip_non_complete(self, tmp_project):
td = tmp_project / ".automaton" / "tasks" / "wip"
td.mkdir(parents=True)
(td / ".state").write_text("implement\n")
rc = st.cmd_cleanup_done(_task_ns(days=0, project=str(tmp_project)))
assert rc == 0
assert td.exists()
assert (td / ".state").read_text().strip() == "implement"
def test_skip_when_dest_exists(self, tmp_project, capsys):
_make_complete_task(tmp_project, "dup", age_days=9.0)
dest = tmp_project / ".automaton" / "tasks" / "complete" / "dup"
dest.mkdir(parents=True)
(dest / ".state").write_text("complete\n")
rc = st.cmd_cleanup_done(_task_ns(days=7, project=str(tmp_project)))
assert rc == 0
out = capsys.readouterr().out
assert "skipped" in out and "dest_exists" in out
def test_json_summary(self, tmp_project, capsys):
_make_complete_task(tmp_project, "json-old", age_days=9.0)
_make_complete_task(tmp_project, "json-new", age_days=1.0)
st.cmd_cleanup_done(_task_ns(days=7, json_out=True, project=str(tmp_project)))
out = capsys.readouterr().out
last = out.strip().splitlines()[-1]
summary = json.loads(last)
assert summary["days"] == 7
assert summary["dry_run"] is False
assert "json-old" in summary["moved"]
assert "json-new" not in summary["moved"]
def test_all_task_dirs_excludes_complete_container(self, tmp_project):
_make_complete_task(tmp_project, "active-done", age_days=0.0)
archive = tmp_project / ".automaton" / "tasks" / "complete" / "archived"
archive.mkdir(parents=True)
names = {n for n, _ in st._all_task_dirs(str(tmp_project))}
assert "active-done" in names
assert "complete" not in names
assert "archived" not in names
class TestInstallCleanupScheduleIsolation:
def _run_cli(self, args, env_dir):
env = os.environ.copy()
env["AUTOMATON_LAUNCHAGENTS_DIR"] = str(env_dir)
cmd = [sys.executable, str(_STATUS_PATH)] + args
return subprocess.run(cmd, capture_output=True, text=True, env=env)
def test_plist_written_to_override_dir_not_host(self, tmp_path):
agents = tmp_path / "agents"
agents.mkdir()
res = self._run_cli(
["--install-cleanup-schedule", "--days", "5", "--interval", "120",
"--project", str(tmp_path)],
agents,
)
assert res.returncode == 0, res.stdout + res.stderr
plist = agents / "com.automaton.cleanup.plist"
assert plist.exists()
# Nothing bled to the real host LaunchAgents.
host = Path.home() / "Library" / "LaunchAgents" / "com.automaton.cleanup.plist"
assert not host.exists()
content = plist.read_text()
assert "com.automaton.cleanup" in content
assert "<integer>120</integer>" in content
stub = Path(st.AUTOMATON_DIR) / "scripts" / "automaton-cleanup.sh"
assert stub.exists()
assert "--days 5" in stub.read_text()
@pytest.mark.skipif(sys.platform != "darwin", reason="Darwin plist branch")
def test_uninstall_via_disabled_rename(self, tmp_path):
agents = tmp_path / "agents"
agents.mkdir()
res = self._run_cli(
["--install-cleanup-schedule", "--project", str(tmp_path)], agents)
assert res.returncode == 0
plist = agents / "com.automaton.cleanup.plist"
assert plist.exists()
+13
View File
@@ -134,6 +134,19 @@ def test_discover_tasks(tmp_path: Path) -> None:
assert tasks[1].state == TaskState.RESEARCH
def test_discover_tasks_skips_complete_container(tmp_path: Path) -> None:
_make_task(tmp_path, "active", {"SPEC.md": "# Spec"})
complete = tmp_path / "complete"
complete.mkdir()
(complete / "done-task").mkdir()
(complete / "done-task" / "VERDICT.md").write_text("## Status: PASS")
(complete / "REVIEW.md").write_text("# Review\n- **Status**: approved")
tasks = discover_tasks(tmp_path)
assert len(tasks) == 1
assert tasks[0].name == "active"
def test_discover_tasks_skips_subtasks_root(tmp_path: Path) -> None:
parent = tmp_path / "parent"
parent.mkdir()