Archive completed tasks, add cleanup commands, self-documenting dashboard UI
CI / build (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user