feat(daemon): systemd user timer auto-runner — decentralized scheduler fires hands-off (FPLAN-0287)
This commit is contained in:
@@ -13,6 +13,20 @@ PyPI version — not the changelog header.
|
||||
|
||||
### Added
|
||||
|
||||
- **Daemon auto-runner — systemd user timer (the deferred last mile of the
|
||||
decentralized scheduler)** — `.daemon/schedule.json` jobs now fire **hands-off**.
|
||||
A oneshot `daemon-tick.service` + `daemon-tick.timer` (every ~2 min, mirroring
|
||||
the `prax-monitor.service` pattern: user-scope `~/.config/systemd/user/`, `%h`
|
||||
not hardcoded paths, venv-python ExecStart `-m aipass.daemon.apps.daemon run`,
|
||||
logs to `~/.aipass/daemon-tick.log` outside any tailed dir) reuses the existing
|
||||
fcntl-locked `run.py` tick unchanged — the timer is the ticker. New
|
||||
`apps/modules/timer_install.py` installs/enables it idempotently. Live-proven:
|
||||
@devpulse received a `DAEMON TEST` ping from a branch woken purely by the timer,
|
||||
no human tick. Tick profile: ~1.7s (import overhead only); the earlier CPU spike
|
||||
was `wake_branch` spawning opus agents concurrently, **not** the tick — so
|
||||
scheduled wakes want light models + staggering. Closes the piece DPLAN-0204 /
|
||||
FPLAN-0282 deferred. 461 daemon tests green, seedgo 100%. (FPLAN-0287)
|
||||
|
||||
- **Prax monitor → Telegram relay (`prax_monitor` bot)** — the live
|
||||
`drone @prax monitor run` Mission-Control feed now mirrors to a dedicated
|
||||
Telegram bot, so the whole-system monitor is watchable from a phone ("same
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"branch": "@backup",
|
||||
"jobs": [
|
||||
{
|
||||
"id": "wake-test",
|
||||
"enabled": false,
|
||||
"schedule": {
|
||||
"type": "interval",
|
||||
"interval_minutes": 5
|
||||
},
|
||||
"wake": {
|
||||
"fresh": true
|
||||
},
|
||||
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST \u2014 backup\" \"Woke via @daemon systemd timer. No memory touched.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"branch": "@cli",
|
||||
"jobs": [
|
||||
{
|
||||
"id": "wake-test",
|
||||
"enabled": false,
|
||||
"schedule": {
|
||||
"type": "interval",
|
||||
"interval_minutes": 5
|
||||
},
|
||||
"wake": {
|
||||
"fresh": true
|
||||
},
|
||||
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST \u2014 cli\" \"Woke via @daemon systemd timer. No memory touched.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,10 +7,11 @@
|
||||
"enabled": false,
|
||||
"schedule": {
|
||||
"type": "interval",
|
||||
"interval_minutes": 1
|
||||
"interval_minutes": 5
|
||||
},
|
||||
"wake": {
|
||||
"fresh": true
|
||||
"fresh": true,
|
||||
"model": "haiku"
|
||||
},
|
||||
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST FIRED\" \"Wake received from @daemon scheduler. No memory updated.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
|
||||
}
|
||||
|
||||
@@ -142,6 +142,24 @@
|
||||
"standard": "architecture",
|
||||
"reason": "Scheduler plugin — autodiscovered by plugins/__init__.py discover_plugins(). Lives in apps/plugins/ by design, not a module or handler.",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_timer_install.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — lives in tests/ by convention, not in 3-layer apps/ structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/timer_install.py",
|
||||
"standard": "introspection",
|
||||
"reason": "install-timer/uninstall-timer are action commands — no-args IS the action (install). Introspection gate would break primary use case (same pattern as run.py).",
|
||||
"pattern": "no-args gate"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/timer_install.py",
|
||||
"standard": "modules",
|
||||
"reason": "Installer module — mkdir for ~/.config/systemd/user/ is the core purpose. Direct file ops are inherent to the install function.",
|
||||
"pattern": "direct file operations"
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -94,6 +94,8 @@ drone @daemon actions list # Action registry
|
||||
drone @daemon actions <id> on/off # Toggle action
|
||||
drone @daemon actions set reminder 7d "msg" --to @branch
|
||||
drone @daemon actions set schedule @branch "prompt" daily 04:00
|
||||
drone @daemon install-timer # Install + enable systemd user timer
|
||||
drone @daemon uninstall-timer # Stop + remove systemd user timer
|
||||
```
|
||||
|
||||
Each module accepts `--help` for module-specific usage:
|
||||
@@ -113,6 +115,8 @@ drone @daemon <command> --help
|
||||
| `actions` | Action registry CLI — list, toggle, info, set reminder, set schedule, migrate | Operational |
|
||||
| `scheduler_ops` | Scheduler cron operations facade for scheduler_cron.py | Operational |
|
||||
| `wakeup_ops` | Wake-up cron operations facade for daemon_wakeup.py | Operational |
|
||||
| `timer_install` | Idempotent systemd user timer installer for daemon scheduler | Operational |
|
||||
| `run` | Decentralized scheduler tick: discover .daemon/ jobs, fire due ones | Operational |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
# Console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
from aipass.daemon.apps.modules import update, schedule, activity_report, actions, run
|
||||
from aipass.daemon.apps.modules import update, schedule, activity_report, actions, run, timer_install
|
||||
|
||||
|
||||
def _header(text):
|
||||
@@ -46,7 +46,7 @@ def get_modules() -> List[Any]:
|
||||
List of module objects with handle_command function
|
||||
"""
|
||||
modules = []
|
||||
for mod in [update, schedule, activity_report, actions, run]:
|
||||
for mod in [update, schedule, activity_report, actions, run, timer_install]:
|
||||
if hasattr(mod, "handle_command"):
|
||||
modules.append(mod)
|
||||
return modules
|
||||
@@ -142,6 +142,8 @@ def print_help(modules: List[Any]):
|
||||
("branch-health", "Single branch deep dive (e.g., branch-health DAEMON)."),
|
||||
("actions", "CLI interface for the numbered action registry."),
|
||||
("run", "One scheduler tick: discover .daemon/ jobs, fire due ones."),
|
||||
("install-timer", "Install + enable daemon-tick systemd user timer (~2 min)."),
|
||||
("uninstall-timer", "Stop + remove daemon-tick systemd user timer."),
|
||||
]
|
||||
|
||||
for cmd_name, desc in _COMMAND_HELP:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: timer_install.py
|
||||
# Description: Idempotent systemd user timer installer for daemon scheduler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-25
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Timer installer — idempotent install/uninstall of daemon-tick systemd user units.
|
||||
|
||||
Handles 'drone @daemon install-timer' and 'drone @daemon uninstall-timer'.
|
||||
Copies daemon-tick.service + daemon-tick.timer to ~/.config/systemd/user/,
|
||||
reloads systemd, and enables/starts the timer.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
_DAEMON_ROOT = Path(__file__).resolve().parents[2]
|
||||
_UNIT_DIR = Path.home() / ".config" / "systemd" / "user"
|
||||
_SERVICE_NAME = "daemon-tick.service"
|
||||
_TIMER_NAME = "daemon-tick.timer"
|
||||
|
||||
HANDLED_COMMANDS = {"install-timer", "uninstall-timer"}
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]timer_install Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Idempotent systemd user timer installer for daemon scheduler[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Unit files:[/yellow]")
|
||||
console.print(f" [cyan]*[/cyan] {_DAEMON_ROOT / _SERVICE_NAME}")
|
||||
console.print(f" [cyan]*[/cyan] {_DAEMON_ROOT / _TIMER_NAME}")
|
||||
console.print(f" [cyan]*[/cyan] Installs to: {_UNIT_DIR}/")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display usage information."""
|
||||
console.print("\n[bold cyan]install-timer / uninstall-timer — Daemon Scheduler Timer[/bold cyan]")
|
||||
console.print("\n[yellow]USAGE:[/yellow]")
|
||||
console.print(" drone @daemon install-timer Install + enable daemon-tick timer")
|
||||
console.print(" drone @daemon uninstall-timer Stop + remove daemon-tick timer")
|
||||
console.print(" drone @daemon install-timer --help")
|
||||
console.print("\n[yellow]DESCRIPTION:[/yellow]")
|
||||
console.print(" Copies daemon-tick.service and daemon-tick.timer to")
|
||||
console.print(f" {_UNIT_DIR}/")
|
||||
console.print(" Then reloads systemd and enables+starts the timer.")
|
||||
console.print(" Idempotent — safe to run multiple times.")
|
||||
console.print()
|
||||
|
||||
|
||||
def _run_systemctl(*args: str) -> bool:
|
||||
"""Run a systemctl --user command. Returns True on success."""
|
||||
cmd = ["systemctl", "--user", *args]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
if result.returncode != 0:
|
||||
logger.warning("[timer_install] systemctl --user %s failed: %s", " ".join(args), result.stderr.strip())
|
||||
console.print(f" [red]FAIL:[/red] systemctl --user {' '.join(args)}")
|
||||
if result.stderr.strip():
|
||||
console.print(f" [dim]{result.stderr.strip()}[/dim]")
|
||||
return False
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.error("[timer_install] systemctl not found — systemd not available")
|
||||
error("systemctl not found — systemd not available")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("[timer_install] systemctl --user %s timed out", " ".join(args))
|
||||
console.print(" [red]systemctl timed out[/red]")
|
||||
return False
|
||||
|
||||
|
||||
def _install() -> int:
|
||||
"""Install and enable the daemon-tick timer."""
|
||||
service_src = _DAEMON_ROOT / _SERVICE_NAME
|
||||
timer_src = _DAEMON_ROOT / _TIMER_NAME
|
||||
|
||||
for src in (service_src, timer_src):
|
||||
if not src.exists():
|
||||
logger.error("[timer_install] Missing unit file: %s", src)
|
||||
return 1
|
||||
|
||||
_UNIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_handler.log_operation("install_timer", {"target": str(_UNIT_DIR)})
|
||||
|
||||
console.print("[bold cyan]Installing daemon-tick units...[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
for src in (service_src, timer_src):
|
||||
dst = _UNIT_DIR / src.name
|
||||
shutil.copy2(src, dst)
|
||||
console.print(f" [green]Copied:[/green] {src.name} -> {dst}")
|
||||
|
||||
console.print()
|
||||
|
||||
console.print(" Reloading systemd user daemon...")
|
||||
if not _run_systemctl("daemon-reload"):
|
||||
return 1
|
||||
|
||||
console.print(" Enabling daemon-tick.timer...")
|
||||
if not _run_systemctl("enable", _TIMER_NAME):
|
||||
return 1
|
||||
|
||||
console.print(" Starting daemon-tick.timer...")
|
||||
if not _run_systemctl("start", _TIMER_NAME):
|
||||
return 1
|
||||
|
||||
console.print()
|
||||
console.print("[bold green]daemon-tick.timer installed and active.[/bold green]")
|
||||
console.print("[dim]Verify: systemctl --user list-timers | grep daemon[/dim]")
|
||||
console.print()
|
||||
|
||||
Path.home().joinpath(".aipass").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info("[timer_install] daemon-tick timer installed and started")
|
||||
return 0
|
||||
|
||||
|
||||
def _uninstall() -> int:
|
||||
"""Stop, disable, and remove the daemon-tick timer."""
|
||||
console.print("[bold cyan]Uninstalling daemon-tick units...[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
_run_systemctl("stop", _TIMER_NAME)
|
||||
_run_systemctl("disable", _TIMER_NAME)
|
||||
|
||||
for name in (_SERVICE_NAME, _TIMER_NAME):
|
||||
dst = _UNIT_DIR / name
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
|
||||
trigger.fire("file_deleted", path=str(dst), source="timer_install")
|
||||
except ImportError:
|
||||
logger.info("[timer_install] Trigger module not available, skipping event fire")
|
||||
except Exception as e:
|
||||
logger.warning("[timer_install] Trigger fire failed (non-critical): %s", e)
|
||||
console.print(f" [yellow]Removed:[/yellow] {dst}")
|
||||
else:
|
||||
console.print(f" [dim]Not found:[/dim] {dst}")
|
||||
|
||||
_run_systemctl("daemon-reload")
|
||||
|
||||
console.print()
|
||||
console.print("[bold green]daemon-tick units removed.[/bold green]")
|
||||
console.print()
|
||||
|
||||
logger.info("[timer_install] daemon-tick timer uninstalled")
|
||||
return 0
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Handle install-timer / uninstall-timer commands."""
|
||||
if command not in HANDLED_COMMANDS:
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if command == "install-timer":
|
||||
exit_code = _install()
|
||||
else:
|
||||
exit_code = _uninstall()
|
||||
|
||||
if exit_code != 0:
|
||||
sys.exit(exit_code)
|
||||
return True
|
||||
@@ -0,0 +1,22 @@
|
||||
# Systemd user service for the daemon scheduler tick (oneshot).
|
||||
#
|
||||
# Install:
|
||||
# cp daemon-tick.service daemon-tick.timer ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now daemon-tick.timer
|
||||
#
|
||||
# Usage:
|
||||
# systemctl --user start daemon-tick # manual single tick
|
||||
# systemctl --user list-timers # check timer schedule
|
||||
# systemctl --user status daemon-tick # last tick result
|
||||
# journalctl --user -u daemon-tick # journal entries
|
||||
|
||||
[Unit]
|
||||
Description=AIPass Daemon Scheduler Tick — discover and fire due .daemon/ jobs
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/Projects/AIPass/.venv/bin/python3 -m aipass.daemon.apps.daemon run
|
||||
WorkingDirectory=%h/Projects/AIPass
|
||||
StandardOutput=append:%h/.aipass/daemon-tick.log
|
||||
StandardError=append:%h/.aipass/daemon-tick.log
|
||||
@@ -0,0 +1,20 @@
|
||||
# Systemd user timer for the daemon scheduler tick (~2 min cadence).
|
||||
#
|
||||
# Install:
|
||||
# cp daemon-tick.service daemon-tick.timer ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now daemon-tick.timer
|
||||
#
|
||||
# Disable:
|
||||
# systemctl --user disable --now daemon-tick.timer
|
||||
|
||||
[Unit]
|
||||
Description=AIPass Daemon Scheduler Timer — fires daemon-tick.service every ~2 min
|
||||
|
||||
[Timer]
|
||||
OnActiveSec=30s
|
||||
OnUnitActiveSec=2min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,150 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_timer_install.py
|
||||
# Description: Tests for the timer_install module (systemd user timer installer)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-25
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for the timer_install module (systemd user timer installer)."""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.daemon.apps.modules.timer_install import (
|
||||
handle_command,
|
||||
HANDLED_COMMANDS,
|
||||
_run_systemctl,
|
||||
_install,
|
||||
_uninstall,
|
||||
)
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
"""Tests for command routing."""
|
||||
|
||||
def test_handles_install_timer(self):
|
||||
"""Verify install-timer is in handled commands."""
|
||||
assert "install-timer" in HANDLED_COMMANDS
|
||||
|
||||
def test_handles_uninstall_timer(self):
|
||||
"""Verify uninstall-timer is in handled commands."""
|
||||
assert "uninstall-timer" in HANDLED_COMMANDS
|
||||
|
||||
def test_rejects_unknown(self):
|
||||
"""Unknown commands return False."""
|
||||
assert handle_command("unknown", []) is False
|
||||
|
||||
def test_help_flag(self, capsys):
|
||||
"""Help flag prints usage and returns True."""
|
||||
result = handle_command("install-timer", ["--help"])
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestRunSystemctl:
|
||||
"""Tests for the systemctl wrapper."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_success(self, mock_run):
|
||||
"""Successful systemctl returns True."""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert _run_systemctl("status", "daemon-tick.timer") is True
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_failure_returncode(self, mock_run):
|
||||
"""Non-zero returncode returns False."""
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="unit not found")
|
||||
assert _run_systemctl("start", "daemon-tick.timer") is False
|
||||
|
||||
@patch("subprocess.run", side_effect=FileNotFoundError)
|
||||
def test_systemctl_not_found(self, mock_run):
|
||||
"""Missing systemctl returns False."""
|
||||
assert _run_systemctl("status", "daemon-tick.timer") is False
|
||||
|
||||
@patch(
|
||||
"subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="systemctl", timeout=15),
|
||||
)
|
||||
def test_timeout(self, mock_run):
|
||||
"""Timed-out systemctl returns False."""
|
||||
assert _run_systemctl("status", "daemon-tick.timer") is False
|
||||
|
||||
|
||||
class TestInstall:
|
||||
"""Tests for the install flow."""
|
||||
|
||||
def test_install_missing_unit_file(self):
|
||||
"""Returns 1 when unit files are missing."""
|
||||
with patch(
|
||||
"aipass.daemon.apps.modules.timer_install._DAEMON_ROOT",
|
||||
Path("/nonexistent"),
|
||||
):
|
||||
result = _install()
|
||||
assert result == 1
|
||||
|
||||
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
|
||||
@patch("shutil.copy2")
|
||||
def test_install_success(self, mock_copy, mock_systemctl, tmp_path):
|
||||
"""Successful install copies files and calls systemctl 3 times."""
|
||||
service = tmp_path / "daemon-tick.service"
|
||||
timer = tmp_path / "daemon-tick.timer"
|
||||
service.write_text("[Unit]\n")
|
||||
timer.write_text("[Unit]\n")
|
||||
|
||||
install_dir = tmp_path / "systemd"
|
||||
install_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path),
|
||||
patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir),
|
||||
):
|
||||
result = _install()
|
||||
assert result == 0
|
||||
assert mock_systemctl.call_count == 3
|
||||
|
||||
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl")
|
||||
@patch("shutil.copy2")
|
||||
def test_install_systemctl_fails(self, mock_copy, mock_systemctl, tmp_path):
|
||||
"""Returns 1 when systemctl fails."""
|
||||
service = tmp_path / "daemon-tick.service"
|
||||
timer = tmp_path / "daemon-tick.timer"
|
||||
service.write_text("[Unit]\n")
|
||||
timer.write_text("[Unit]\n")
|
||||
|
||||
install_dir = tmp_path / "systemd"
|
||||
install_dir.mkdir()
|
||||
|
||||
mock_systemctl.return_value = False
|
||||
|
||||
with (
|
||||
patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path),
|
||||
patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir),
|
||||
):
|
||||
result = _install()
|
||||
assert result == 1
|
||||
|
||||
|
||||
class TestUninstall:
|
||||
"""Tests for the uninstall flow."""
|
||||
|
||||
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
|
||||
def test_uninstall_files_not_present(self, mock_systemctl, tmp_path):
|
||||
"""Returns 0 even when unit files are already absent."""
|
||||
with patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", tmp_path):
|
||||
result = _uninstall()
|
||||
assert result == 0
|
||||
|
||||
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
|
||||
def test_uninstall_removes_files(self, mock_systemctl, tmp_path):
|
||||
"""Removes unit files from the target directory."""
|
||||
service = tmp_path / "daemon-tick.service"
|
||||
timer = tmp_path / "daemon-tick.timer"
|
||||
service.write_text("[Unit]\n")
|
||||
timer.write_text("[Unit]\n")
|
||||
|
||||
with patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", tmp_path):
|
||||
result = _uninstall()
|
||||
assert result == 0
|
||||
assert not service.exists()
|
||||
assert not timer.exists()
|
||||
Reference in New Issue
Block a user