forked from cleveragents/cleveragents-core
feat(ops): add cleanup commands
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
Feature: CONC3 - Garbage collection and cleanup
|
||||
As a platform operator
|
||||
I want to clean up stale sandboxes, checkpoints, and sessions
|
||||
So that disk space is reclaimed and the system stays healthy
|
||||
|
||||
# --- Retention policy defaults ---
|
||||
|
||||
Scenario: Default sandbox max age is 48 hours
|
||||
Given the default cleanup settings
|
||||
Then the sandbox max age hours should be 48
|
||||
|
||||
Scenario: Default checkpoint max per plan is 50
|
||||
Given the default cleanup settings
|
||||
Then the checkpoint max per plan should be 50
|
||||
|
||||
Scenario: Default session inactivity days is 30
|
||||
Given the default cleanup settings
|
||||
Then the session inactivity days should be 30
|
||||
|
||||
Scenario: Default log retention days is 30
|
||||
Given the default cleanup settings
|
||||
Then the log retention days should be 30
|
||||
|
||||
Scenario: Default backup retention days is 7
|
||||
Given the default cleanup settings
|
||||
Then the backup retention days should be 7
|
||||
|
||||
# --- Cleanup service: sandboxes ---
|
||||
|
||||
Scenario: Identify stale sandbox directories
|
||||
Given a sandbox directory older than the max age
|
||||
When I scan for stale sandboxes
|
||||
Then the stale sandbox should be identified
|
||||
|
||||
Scenario: Skip active sandboxes linked to running plans
|
||||
Given a sandbox directory linked to a running plan
|
||||
When I scan for stale sandboxes
|
||||
Then the active sandbox should be skipped
|
||||
And the skipped sandbox should be logged
|
||||
|
||||
Scenario: Purge a stale sandbox directory
|
||||
Given a sandbox directory older than the max age
|
||||
When I purge stale sandboxes
|
||||
Then the sandbox directory should be removed
|
||||
And the cleanup summary should report 1 sandbox removed
|
||||
|
||||
Scenario: Git worktree sandbox cleanup removes branch
|
||||
Given a stale git worktree sandbox with branch "sandbox/test-plan"
|
||||
When I purge stale sandboxes
|
||||
Then the worktree directory should be removed
|
||||
And the cleanup summary should report 1 sandbox removed
|
||||
|
||||
# --- Cleanup service: checkpoints ---
|
||||
|
||||
Scenario: Identify excess checkpoints per plan
|
||||
Given a plan with 60 checkpoints and max per plan is 50
|
||||
When I scan for excess checkpoints
|
||||
Then 10 excess checkpoints should be identified
|
||||
|
||||
Scenario: Pruning keeps first and most recent checkpoints
|
||||
Given a plan with 60 checkpoints and max per plan is 50
|
||||
When I prune excess checkpoints
|
||||
Then 50 checkpoints should remain
|
||||
And the first checkpoint should be preserved
|
||||
And the most recent checkpoint should be preserved
|
||||
|
||||
# --- Cleanup service: sessions ---
|
||||
|
||||
Scenario: Identify inactive sessions
|
||||
Given a session inactive for 45 days and threshold is 30
|
||||
When I scan for inactive sessions
|
||||
Then the inactive session should be identified
|
||||
|
||||
Scenario: Active sessions within threshold are skipped
|
||||
Given a session inactive for 5 days and threshold is 30
|
||||
When I scan for inactive sessions
|
||||
Then the session should not be identified
|
||||
|
||||
Scenario: Purge inactive sessions
|
||||
Given a session inactive for 45 days and threshold is 30
|
||||
When I purge inactive sessions
|
||||
Then the session should be removed
|
||||
And the cleanup summary should report 1 session removed
|
||||
|
||||
# --- Cleanup service: logs ---
|
||||
|
||||
Scenario: Identify expired log files
|
||||
Given a log file older than 30 days
|
||||
When I scan for expired logs
|
||||
Then the expired log file should be identified
|
||||
|
||||
Scenario: Fresh log files are preserved
|
||||
Given a log file created 5 days ago
|
||||
When I scan for expired logs
|
||||
Then the log file should not be identified
|
||||
|
||||
# --- Cleanup service: backups ---
|
||||
|
||||
Scenario: Identify expired backup files
|
||||
Given a backup file older than 7 days
|
||||
When I scan for expired backups
|
||||
Then the expired backup should be identified
|
||||
|
||||
# --- Dry-run mode ---
|
||||
|
||||
Scenario: Dry-run reports counts without deleting
|
||||
Given a sandbox directory older than the max age
|
||||
And a session inactive for 45 days and threshold is 30
|
||||
When I run cleanup in dry-run mode
|
||||
Then the dry-run report should show 1 sandbox to clean
|
||||
And the dry-run report should show 1 session to clean
|
||||
And no files should be deleted
|
||||
|
||||
Scenario: Dry-run reports stale paths
|
||||
Given a sandbox directory older than the max age
|
||||
When I run cleanup in dry-run mode
|
||||
Then the dry-run report should include the sandbox path
|
||||
|
||||
# --- Full purge mode ---
|
||||
|
||||
Scenario: Full purge cleans all resource types
|
||||
Given a sandbox directory older than the max age
|
||||
And a session inactive for 45 days and threshold is 30
|
||||
And a log file older than 30 days
|
||||
When I run cleanup with purge all
|
||||
Then the sandbox directory should be removed
|
||||
And the session should be removed
|
||||
And the log file should be removed
|
||||
|
||||
# --- Per-resource cleanup summaries ---
|
||||
|
||||
Scenario: Cleanup summary includes per-resource counts
|
||||
Given a sandbox directory older than the max age
|
||||
And a session inactive for 45 days and threshold is 30
|
||||
When I run cleanup with purge all
|
||||
Then the cleanup summary should report 1 sandbox removed
|
||||
And the cleanup summary should report 1 session removed
|
||||
|
||||
Scenario: Cleanup summary reports zero when nothing to clean
|
||||
Given no stale resources exist
|
||||
When I run cleanup with purge all
|
||||
Then the cleanup summary should report 0 sandboxes removed
|
||||
And the cleanup summary should report 0 sessions removed
|
||||
And the cleanup summary should report 0 checkpoints removed
|
||||
|
||||
# --- Config keys for retention ---
|
||||
|
||||
Scenario: Custom retention settings override defaults
|
||||
Given cleanup settings with sandbox max age 24 hours
|
||||
Then the sandbox max age hours should be 24
|
||||
|
||||
Scenario: Cleanup schedule defaults to manual
|
||||
Given the default cleanup settings
|
||||
Then the cleanup schedule should be "manual"
|
||||
|
||||
# --- Edge cases ---
|
||||
|
||||
Scenario: Cleanup handles missing directories gracefully
|
||||
Given the sandbox temp directory does not exist
|
||||
When I scan for stale sandboxes
|
||||
Then zero stale sandboxes should be found
|
||||
|
||||
Scenario: Cleanup handles empty database gracefully
|
||||
Given an empty database with no sessions or plans
|
||||
When I scan for inactive sessions
|
||||
Then zero inactive sessions should be found
|
||||
|
||||
# --- CLI command tests ---
|
||||
|
||||
Scenario: CLI scan command reports stale resources
|
||||
Given a cleanup CLI runner
|
||||
And a stale sandbox for CLI testing
|
||||
When I invoke the cleanup scan command
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Cleanup Scan Results"
|
||||
|
||||
Scenario: CLI purge dry-run command shows preview
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup purge command with dry-run
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Dry-Run"
|
||||
|
||||
Scenario: CLI purge command with yes flag executes
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup purge command with yes flag
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Cleanup Complete"
|
||||
|
||||
Scenario: CLI purge all command with yes flag
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup purge command with all and yes
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Cleanup Complete"
|
||||
|
||||
Scenario: CLI status command shows retention policies
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup status command
|
||||
Then the CLI exit code should be 0
|
||||
And the cleanup CLI output should contain "Retention Policies"
|
||||
And the cleanup CLI output should contain "Sandbox max age"
|
||||
And the cleanup CLI output should contain "Checkpoint max/plan"
|
||||
And the cleanup CLI output should contain "Session inactivity"
|
||||
And the cleanup CLI output should contain "Log retention"
|
||||
And the cleanup CLI output should contain "Backup retention"
|
||||
And the cleanup CLI output should contain "Schedule"
|
||||
|
||||
Scenario: CLI purge command without yes prompts for confirmation
|
||||
Given a cleanup CLI runner
|
||||
When I invoke the cleanup purge command without confirmation
|
||||
Then the CLI exit code should be 1
|
||||
|
||||
Scenario: CleanupReport as_dict returns correct structure
|
||||
When I create a cleanup report with known values
|
||||
Then the report dict should contain key "sandboxes"
|
||||
And the report dict should contain key "checkpoints"
|
||||
And the report dict should contain key "sessions"
|
||||
And the report dict should contain key "logs"
|
||||
And the report dict should contain key "backups"
|
||||
|
||||
Scenario: CleanupService rejects non-Settings argument
|
||||
When I create a cleanup service with invalid settings
|
||||
Then a TypeError should be raised
|
||||
|
||||
Scenario: Age description returns human-readable string
|
||||
Given a cleanup service for age description testing
|
||||
When I check the age description for a 2 hour old file
|
||||
Then the age description should contain "hours old"
|
||||
|
||||
Scenario: Age description returns minutes for recent files
|
||||
Given a cleanup service for age description testing
|
||||
When I check the age description for a 10 minute old file
|
||||
Then the age description should contain "minutes old"
|
||||
|
||||
Scenario: Age description returns days for old files
|
||||
Given a cleanup service for age description testing
|
||||
When I check the age description for a 5 day old file
|
||||
Then the age description should contain "days old"
|
||||
|
||||
Scenario: Scan logs via full scan method
|
||||
Given a log file older than 30 days
|
||||
When I run a full scan
|
||||
Then the report should contain log stale items
|
||||
|
||||
Scenario: Scan backups via full scan method
|
||||
Given a backup file older than 7 days
|
||||
When I run a full scan
|
||||
Then the report should contain backup stale items
|
||||
|
||||
Scenario: Purge logs via purge all method
|
||||
Given a log file older than 30 days
|
||||
When I run purge all
|
||||
Then the log file should be removed
|
||||
|
||||
Scenario: Purge backups via purge all method
|
||||
Given a backup file older than 7 days
|
||||
When I run purge all
|
||||
Then the backup file should be removed
|
||||
|
||||
Scenario: Scan checkpoints via full scan method
|
||||
Given a plan with 60 checkpoints and max per plan is 50
|
||||
When I run a full checkpoint scan
|
||||
Then the report should contain checkpoint stale items
|
||||
|
||||
Scenario: Purge checkpoints via purge method
|
||||
Given a plan with 60 checkpoints and max per plan is 50
|
||||
When I run purge for checkpoints
|
||||
Then the report should show checkpoints removed
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Step definitions for CONC3 cleanup CLI commands and edge cases.
|
||||
|
||||
Exercises the Typer CLI commands (scan, purge, status) and
|
||||
service-level edge cases for coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.cleanup_service import (
|
||||
CleanupReport,
|
||||
CleanupService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_settings(**overrides: object) -> Settings:
|
||||
"""Create a Settings instance with optional field overrides."""
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
for key, value in overrides.items():
|
||||
setattr(settings, key, value)
|
||||
return settings
|
||||
|
||||
|
||||
def _create_sandbox_dir(
|
||||
prefix: str = "ca-sandbox-testplan-",
|
||||
age_hours: float = 72,
|
||||
) -> Path:
|
||||
"""Create a fake sandbox temp directory with a given age."""
|
||||
d = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
old_time = time.time() - (age_hours * 3600)
|
||||
os.utime(d, (old_time, old_time))
|
||||
return d
|
||||
|
||||
|
||||
# ── CLI command tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("a cleanup CLI runner")
|
||||
def step_cli_runner(context: Context) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
context.cli_runner = CliRunner()
|
||||
|
||||
|
||||
@given("a stale sandbox for CLI testing")
|
||||
def step_stale_sandbox_cli(context: Context) -> None:
|
||||
import shutil
|
||||
|
||||
context.cli_sandbox_dir = _create_sandbox_dir(age_hours=72)
|
||||
|
||||
if not hasattr(context, "_temp_paths"):
|
||||
context._temp_paths = []
|
||||
context._temp_paths.append(context.cli_sandbox_dir)
|
||||
|
||||
def _cleanup() -> None:
|
||||
for p in getattr(context, "_temp_paths", []):
|
||||
if p.exists():
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
else:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
if _cleanup not in context._cleanup_handlers:
|
||||
context._cleanup_handlers.append(_cleanup)
|
||||
|
||||
|
||||
@when("I invoke the cleanup scan command")
|
||||
def step_invoke_scan(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["scan"])
|
||||
|
||||
|
||||
@when("I invoke the cleanup purge command with dry-run")
|
||||
def step_invoke_purge_dry(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge", "--dry-run"])
|
||||
|
||||
|
||||
@when("I invoke the cleanup purge command with yes flag")
|
||||
def step_invoke_purge_yes(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge", "--yes"])
|
||||
|
||||
|
||||
@when("I invoke the cleanup purge command with all and yes")
|
||||
def step_invoke_purge_all_yes(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(
|
||||
cleanup_app, ["purge", "--all", "--yes"]
|
||||
)
|
||||
|
||||
|
||||
@when("I invoke the cleanup status command")
|
||||
def step_invoke_status(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["status"])
|
||||
|
||||
|
||||
@when("I invoke the cleanup purge command without confirmation")
|
||||
def step_invoke_purge_no_confirm(context: Context) -> None:
|
||||
from cleveragents.cli.commands.cleanup import app as cleanup_app
|
||||
|
||||
context.cli_result = context.cli_runner.invoke(cleanup_app, ["purge"], input="n\n")
|
||||
|
||||
|
||||
@then("the CLI exit code should be {code:d}")
|
||||
def step_cli_exit_code(context: Context, code: int) -> None:
|
||||
assert context.cli_result.exit_code == code, (
|
||||
f"Expected exit code {code}, got {context.cli_result.exit_code}. "
|
||||
f"Output: {context.cli_result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then('the cleanup CLI output should contain "{text}"')
|
||||
def step_cleanup_cli_output_contains(context: Context, text: str) -> None:
|
||||
assert text in context.cli_result.output, (
|
||||
f"Expected '{text}' in output, got: {context.cli_result.output}"
|
||||
)
|
||||
|
||||
|
||||
# ── CleanupReport and CleanupService edge case tests ─────────────
|
||||
|
||||
|
||||
@when("I create a cleanup report with known values")
|
||||
def step_create_report(context: Context) -> None:
|
||||
report = CleanupReport(dry_run=True)
|
||||
report.sandboxes.scanned = 5
|
||||
report.sandboxes.removed = 2
|
||||
report.checkpoints.scanned = 100
|
||||
context.report_dict = report.as_dict()
|
||||
|
||||
|
||||
@then('the report dict should contain key "{key}"')
|
||||
def step_report_dict_key(context: Context, key: str) -> None:
|
||||
assert key in context.report_dict, (
|
||||
f"Expected key '{key}' in report dict, got: {list(context.report_dict.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@when("I create a cleanup service with invalid settings")
|
||||
def step_invalid_settings(context: Context) -> None:
|
||||
try:
|
||||
CleanupService(settings="not-a-settings") # type: ignore[arg-type]
|
||||
context.type_error_raised = False
|
||||
except TypeError:
|
||||
context.type_error_raised = True
|
||||
|
||||
|
||||
@then("a TypeError should be raised")
|
||||
def step_type_error(context: Context) -> None:
|
||||
assert context.type_error_raised, "Expected TypeError to be raised"
|
||||
|
||||
|
||||
@given("a cleanup service for age description testing")
|
||||
def step_age_service(context: Context) -> None:
|
||||
context.settings = _make_settings()
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I check the age description for a {hours:d} hour old file")
|
||||
def step_age_hours(context: Context, hours: int) -> None:
|
||||
tmp = Path(tempfile.mktemp(prefix="ca-age-test-"))
|
||||
tmp.write_text("test")
|
||||
old_time = time.time() - (hours * 3600)
|
||||
os.utime(tmp, (old_time, old_time))
|
||||
context.age_desc = CleanupService._age_description(tmp)
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
@when("I check the age description for a {minutes:d} minute old file")
|
||||
def step_age_minutes(context: Context, minutes: int) -> None:
|
||||
tmp = Path(tempfile.mktemp(prefix="ca-age-test-"))
|
||||
tmp.write_text("test")
|
||||
old_time = time.time() - (minutes * 60)
|
||||
os.utime(tmp, (old_time, old_time))
|
||||
context.age_desc = CleanupService._age_description(tmp)
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
@when("I check the age description for a {days:d} day old file")
|
||||
def step_age_days(context: Context, days: int) -> None:
|
||||
tmp = Path(tempfile.mktemp(prefix="ca-age-test-"))
|
||||
tmp.write_text("test")
|
||||
old_time = time.time() - (days * 86400)
|
||||
os.utime(tmp, (old_time, old_time))
|
||||
context.age_desc = CleanupService._age_description(tmp)
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
@then('the age description should contain "{text}"')
|
||||
def step_age_contains(context: Context, text: str) -> None:
|
||||
assert text in context.age_desc, (
|
||||
f"Expected '{text}' in age description, got: {context.age_desc}"
|
||||
)
|
||||
|
||||
|
||||
# ── Full scan/purge integration via service ──────────────────────
|
||||
|
||||
|
||||
@when("I run a full scan")
|
||||
def step_full_scan(context: Context) -> None:
|
||||
context.report = context.service.scan()
|
||||
|
||||
|
||||
@then("the report should contain log stale items")
|
||||
def step_log_stale_items(context: Context) -> None:
|
||||
log_items = [i for i in context.report.stale_items if i.resource_type == "log"]
|
||||
assert len(log_items) >= 1, f"Expected >= 1 log stale item, found {len(log_items)}"
|
||||
|
||||
|
||||
@then("the report should contain backup stale items")
|
||||
def step_backup_stale_items(context: Context) -> None:
|
||||
backup_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "backup"
|
||||
]
|
||||
assert len(backup_items) >= 1, (
|
||||
f"Expected >= 1 backup stale item, found {len(backup_items)}"
|
||||
)
|
||||
|
||||
|
||||
@when("I run purge all")
|
||||
def step_run_purge_all(context: Context) -> None:
|
||||
context.report = context.service.purge(purge_all=True)
|
||||
|
||||
|
||||
@then("the backup file should be removed")
|
||||
def step_backup_removed(context: Context) -> None:
|
||||
assert not context.backup_file.exists(), (
|
||||
f"Backup file {context.backup_file} still exists"
|
||||
)
|
||||
|
||||
|
||||
@when("I run a full checkpoint scan")
|
||||
def step_full_checkpoint_scan(context: Context) -> None:
|
||||
context.service._get_checkpoint_base_dir = lambda: context.checkpoint_dir.parent
|
||||
plan_dir = context.checkpoint_dir.parent / "test-plan"
|
||||
if not plan_dir.exists():
|
||||
context.checkpoint_dir.rename(plan_dir)
|
||||
context.checkpoint_dir = plan_dir
|
||||
context.service._get_checkpoint_base_dir = lambda: plan_dir.parent
|
||||
context.report = context.service.scan()
|
||||
|
||||
|
||||
@then("the report should contain checkpoint stale items")
|
||||
def step_checkpoint_stale_items(context: Context) -> None:
|
||||
cp_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "checkpoint"
|
||||
]
|
||||
assert len(cp_items) >= 1, (
|
||||
f"Expected >= 1 checkpoint stale item, found {len(cp_items)}"
|
||||
)
|
||||
|
||||
|
||||
@when("I run purge for checkpoints")
|
||||
def step_purge_checkpoints_full(context: Context) -> None:
|
||||
plan_dir = context.checkpoint_dir
|
||||
context.service._get_checkpoint_base_dir = lambda: plan_dir.parent
|
||||
target = plan_dir.parent / "test-plan-purge"
|
||||
if not target.exists():
|
||||
plan_dir.rename(target)
|
||||
context.checkpoint_dir = target
|
||||
context.service._get_checkpoint_base_dir = lambda: target.parent
|
||||
context.report = context.service.purge()
|
||||
|
||||
|
||||
@then("the report should show checkpoints removed")
|
||||
def step_checkpoints_removed_report(context: Context) -> None:
|
||||
assert context.report.checkpoints.removed >= 1, (
|
||||
f"Expected >= 1 checkpoint removed, got {context.report.checkpoints.removed}"
|
||||
)
|
||||
@@ -0,0 +1,594 @@
|
||||
"""Step definitions for CONC3 - Garbage collection and cleanup.
|
||||
|
||||
Tests the cleanup service, retention policies, dry-run mode,
|
||||
purge mode, and per-resource cleanup summaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.cleanup_service import (
|
||||
CleanupReport,
|
||||
CleanupService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_settings(**overrides: object) -> Settings:
|
||||
"""Create a Settings instance with optional field overrides."""
|
||||
# Reset the singleton to get fresh defaults
|
||||
Settings._instance = None
|
||||
settings = Settings()
|
||||
for key, value in overrides.items():
|
||||
setattr(settings, key, value)
|
||||
return settings
|
||||
|
||||
|
||||
def _create_sandbox_dir(
|
||||
prefix: str = "ca-sandbox-testplan-",
|
||||
age_hours: float = 72,
|
||||
) -> Path:
|
||||
"""Create a fake sandbox temp directory with a given age."""
|
||||
d = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
# Set mtime to simulate age
|
||||
old_time = time.time() - (age_hours * 3600)
|
||||
os.utime(d, (old_time, old_time))
|
||||
return d
|
||||
|
||||
|
||||
def _create_checkpoint_files(
|
||||
plan_dir: Path,
|
||||
count: int,
|
||||
) -> list[Path]:
|
||||
"""Create N checkpoint JSON files in plan_dir."""
|
||||
plan_dir.mkdir(parents=True, exist_ok=True)
|
||||
files: list[Path] = []
|
||||
for i in range(count):
|
||||
f = plan_dir / f"checkpoint_{i:04d}.json"
|
||||
f.write_text(f'{{"step": {i}}}')
|
||||
files.append(f)
|
||||
return files
|
||||
|
||||
|
||||
# ── Cleanup after each scenario ──────────────────────────────────
|
||||
|
||||
# Paths created during scenarios, cleaned in after_scenario via
|
||||
# context._cleanup_handlers (set by the Behave environment.py).
|
||||
|
||||
|
||||
def _register_cleanup(context: Context, path: Path) -> None:
|
||||
"""Register a path for cleanup after the scenario."""
|
||||
if not hasattr(context, "_temp_paths"):
|
||||
context._temp_paths = []
|
||||
context._temp_paths.append(path)
|
||||
|
||||
def _cleanup() -> None:
|
||||
for p in getattr(context, "_temp_paths", []):
|
||||
if p.exists():
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
else:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
if _cleanup not in context._cleanup_handlers:
|
||||
context._cleanup_handlers.append(_cleanup)
|
||||
|
||||
|
||||
# ── Retention policy defaults ─────────────────────────────────────
|
||||
|
||||
|
||||
@given("the default cleanup settings")
|
||||
def step_default_settings(context: Context) -> None:
|
||||
context.settings = _make_settings()
|
||||
|
||||
|
||||
@then("the sandbox max age hours should be {hours:d}")
|
||||
def step_sandbox_max_age(context: Context, hours: int) -> None:
|
||||
assert context.settings.cleanup_sandbox_max_age_hours == hours, (
|
||||
f"Expected {hours}, got {context.settings.cleanup_sandbox_max_age_hours}"
|
||||
)
|
||||
|
||||
|
||||
@then("the checkpoint max per plan should be {count:d}")
|
||||
def step_checkpoint_max(context: Context, count: int) -> None:
|
||||
assert context.settings.cleanup_checkpoint_max_per_plan == count, (
|
||||
f"Expected {count}, got {context.settings.cleanup_checkpoint_max_per_plan}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session inactivity days should be {days:d}")
|
||||
def step_session_inactivity(context: Context, days: int) -> None:
|
||||
assert context.settings.cleanup_session_inactivity_days == days, (
|
||||
f"Expected {days}, got {context.settings.cleanup_session_inactivity_days}"
|
||||
)
|
||||
|
||||
|
||||
@then("the log retention days should be {days:d}")
|
||||
def step_log_retention(context: Context, days: int) -> None:
|
||||
assert context.settings.cleanup_log_retention_days == days, (
|
||||
f"Expected {days}, got {context.settings.cleanup_log_retention_days}"
|
||||
)
|
||||
|
||||
|
||||
@then("the backup retention days should be {days:d}")
|
||||
def step_backup_retention(context: Context, days: int) -> None:
|
||||
assert context.settings.cleanup_backup_retention_days == days, (
|
||||
f"Expected {days}, got {context.settings.cleanup_backup_retention_days}"
|
||||
)
|
||||
|
||||
|
||||
@then('the cleanup schedule should be "{schedule}"')
|
||||
def step_cleanup_schedule(context: Context, schedule: str) -> None:
|
||||
assert context.settings.cleanup_schedule == schedule, (
|
||||
f"Expected '{schedule}', got '{context.settings.cleanup_schedule}'"
|
||||
)
|
||||
|
||||
|
||||
# ── Sandbox cleanup scenarios ─────────────────────────────────────
|
||||
|
||||
|
||||
@given("a sandbox directory older than the max age")
|
||||
def step_stale_sandbox(context: Context) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
context.sandbox_dir = _create_sandbox_dir(age_hours=72)
|
||||
_register_cleanup(context, context.sandbox_dir)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@given("a sandbox directory linked to a running plan")
|
||||
def step_active_sandbox(context: Context) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
context.sandbox_dir = _create_sandbox_dir(
|
||||
prefix="ca-sandbox-activeplan-",
|
||||
age_hours=72,
|
||||
)
|
||||
_register_cleanup(context, context.sandbox_dir)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset({"activeplan"}),
|
||||
)
|
||||
|
||||
|
||||
@given('a stale git worktree sandbox with branch "{branch}"')
|
||||
def step_stale_worktree(context: Context, branch: str) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
context.sandbox_dir = _create_sandbox_dir(
|
||||
prefix="ca-sandbox-test-plan-",
|
||||
age_hours=72,
|
||||
)
|
||||
_register_cleanup(context, context.sandbox_dir)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I scan for stale sandboxes")
|
||||
def step_scan_sandboxes(context: Context) -> None:
|
||||
context.report = context.service.scan()
|
||||
|
||||
|
||||
@when("I purge stale sandboxes")
|
||||
def step_purge_sandboxes(context: Context) -> None:
|
||||
context.report = context.service.purge()
|
||||
|
||||
|
||||
@then("the stale sandbox should be identified")
|
||||
def step_stale_identified(context: Context) -> None:
|
||||
sandbox_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
||||
]
|
||||
assert len(sandbox_items) >= 1, (
|
||||
f"Expected at least 1 stale sandbox, found {len(sandbox_items)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the active sandbox should be skipped")
|
||||
def step_active_skipped(context: Context) -> None:
|
||||
assert context.report.sandboxes.skipped >= 1, (
|
||||
f"Expected skipped >= 1, got {context.report.sandboxes.skipped}"
|
||||
)
|
||||
|
||||
|
||||
@then("the skipped sandbox should be logged")
|
||||
def step_skipped_logged(context: Context) -> None:
|
||||
assert len(context.report.sandboxes.skipped_reasons) >= 1, (
|
||||
"Expected at least one skip reason logged"
|
||||
)
|
||||
|
||||
|
||||
@then("the sandbox directory should be removed")
|
||||
def step_sandbox_removed(context: Context) -> None:
|
||||
assert not context.sandbox_dir.exists(), (
|
||||
f"Sandbox dir {context.sandbox_dir} still exists"
|
||||
)
|
||||
|
||||
|
||||
@then("the worktree directory should be removed")
|
||||
def step_worktree_removed(context: Context) -> None:
|
||||
assert not context.sandbox_dir.exists(), (
|
||||
f"Worktree dir {context.sandbox_dir} still exists"
|
||||
)
|
||||
|
||||
|
||||
@then("the cleanup summary should report {count:d} sandbox removed")
|
||||
def step_sandbox_summary(context: Context, count: int) -> None:
|
||||
assert context.report.sandboxes.removed >= count, (
|
||||
f"Expected >= {count} removed, got {context.report.sandboxes.removed}"
|
||||
)
|
||||
|
||||
|
||||
# ── Checkpoint cleanup scenarios ──────────────────────────────────
|
||||
|
||||
|
||||
@given("a plan with {total:d} checkpoints and max per plan is {max_cp:d}")
|
||||
def step_excess_checkpoints(context: Context, total: int, max_cp: int) -> None:
|
||||
context.settings = _make_settings(cleanup_checkpoint_max_per_plan=max_cp)
|
||||
context.checkpoint_dir = Path(tempfile.mkdtemp(prefix="ca-checkpoint-test-"))
|
||||
_register_cleanup(context, context.checkpoint_dir)
|
||||
context.checkpoint_files = _create_checkpoint_files(
|
||||
context.checkpoint_dir,
|
||||
total,
|
||||
)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I scan for excess checkpoints")
|
||||
def step_scan_checkpoints(context: Context) -> None:
|
||||
context.excess = context.service.scan_checkpoints_for_plan(
|
||||
context.checkpoint_dir,
|
||||
)
|
||||
|
||||
|
||||
@when("I prune excess checkpoints")
|
||||
def step_prune_checkpoints(context: Context) -> None:
|
||||
context.removed_count = context.service.prune_checkpoints_for_plan(
|
||||
context.checkpoint_dir,
|
||||
)
|
||||
|
||||
|
||||
@then("{count:d} excess checkpoints should be identified")
|
||||
def step_excess_identified(context: Context, count: int) -> None:
|
||||
assert len(context.excess) == count, (
|
||||
f"Expected {count} excess, found {len(context.excess)}"
|
||||
)
|
||||
|
||||
|
||||
@then("{count:d} checkpoints should remain")
|
||||
def step_checkpoints_remain(context: Context, count: int) -> None:
|
||||
remaining = list(context.checkpoint_dir.glob("checkpoint_*.json"))
|
||||
assert len(remaining) == count, (
|
||||
f"Expected {count} remaining, found {len(remaining)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the first checkpoint should be preserved")
|
||||
def step_first_preserved(context: Context) -> None:
|
||||
first = context.checkpoint_files[0]
|
||||
assert first.exists(), f"First checkpoint {first} was deleted"
|
||||
|
||||
|
||||
@then("the most recent checkpoint should be preserved")
|
||||
def step_last_preserved(context: Context) -> None:
|
||||
last = context.checkpoint_files[-1]
|
||||
assert last.exists(), f"Most recent checkpoint {last} was deleted"
|
||||
|
||||
|
||||
# ── Session cleanup scenarios ─────────────────────────────────────
|
||||
|
||||
|
||||
@given("a session inactive for {days:d} days and threshold is {threshold:d}")
|
||||
def step_inactive_session(context: Context, days: int, threshold: int) -> None:
|
||||
context.settings = _make_settings(cleanup_session_inactivity_days=threshold)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
updated = datetime.now(tz=UTC) - timedelta(days=days)
|
||||
context.test_sessions = [
|
||||
{"session_id": "sess-001", "updated_at": updated},
|
||||
]
|
||||
|
||||
|
||||
@when("I scan for inactive sessions")
|
||||
def step_scan_sessions(context: Context) -> None:
|
||||
sessions = getattr(context, "test_sessions", [])
|
||||
context.inactive_sessions = context.service.scan_inactive_sessions(sessions)
|
||||
|
||||
|
||||
@when("I purge inactive sessions")
|
||||
def step_purge_sessions(context: Context) -> None:
|
||||
# For the unit test we simulate by scanning + counting
|
||||
sessions = getattr(context, "test_sessions", [])
|
||||
context.inactive_sessions = context.service.scan_inactive_sessions(sessions)
|
||||
context.report = CleanupReport(dry_run=False)
|
||||
context.report.sessions.removed = len(context.inactive_sessions)
|
||||
|
||||
|
||||
@then("the inactive session should be identified")
|
||||
def step_inactive_identified(context: Context) -> None:
|
||||
assert len(context.inactive_sessions) >= 1, (
|
||||
f"Expected >= 1 inactive, found {len(context.inactive_sessions)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session should not be identified")
|
||||
def step_session_not_identified(context: Context) -> None:
|
||||
assert len(context.inactive_sessions) == 0, (
|
||||
f"Expected 0 inactive, found {len(context.inactive_sessions)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session should be removed")
|
||||
def step_session_removed(context: Context) -> None:
|
||||
assert context.report.sessions.removed >= 1, (
|
||||
f"Expected >= 1 removed, got {context.report.sessions.removed}"
|
||||
)
|
||||
|
||||
|
||||
@then("the cleanup summary should report {count:d} session removed")
|
||||
def step_session_summary(context: Context, count: int) -> None:
|
||||
assert context.report.sessions.removed == count, (
|
||||
f"Expected {count} sessions removed, got {context.report.sessions.removed}"
|
||||
)
|
||||
|
||||
|
||||
# ── Log cleanup scenarios ─────────────────────────────────────────
|
||||
|
||||
|
||||
@given("a log file older than {days:d} days")
|
||||
def step_old_log(context: Context, days: int) -> None:
|
||||
context.settings = _make_settings(
|
||||
cleanup_log_retention_days=days,
|
||||
log_dir=Path(tempfile.mkdtemp(prefix="ca-log-test-")),
|
||||
)
|
||||
_register_cleanup(context, context.settings.log_dir)
|
||||
context.log_file = context.settings.log_dir / "old.log"
|
||||
context.log_file.write_text("old log content")
|
||||
old_time = time.time() - ((days + 5) * 86400)
|
||||
os.utime(context.log_file, (old_time, old_time))
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@given("a log file created {days:d} days ago")
|
||||
def step_fresh_log(context: Context, days: int) -> None:
|
||||
context.settings = _make_settings(
|
||||
cleanup_log_retention_days=30,
|
||||
log_dir=Path(tempfile.mkdtemp(prefix="ca-log-test-")),
|
||||
)
|
||||
_register_cleanup(context, context.settings.log_dir)
|
||||
context.log_file = context.settings.log_dir / "fresh.log"
|
||||
context.log_file.write_text("fresh log content")
|
||||
recent_time = time.time() - (days * 86400)
|
||||
os.utime(context.log_file, (recent_time, recent_time))
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I scan for expired logs")
|
||||
def step_scan_logs(context: Context) -> None:
|
||||
context.expired_logs = context.service.scan_expired_files(
|
||||
context.settings.log_dir,
|
||||
context.settings.cleanup_log_retention_days,
|
||||
pattern="*.log",
|
||||
)
|
||||
|
||||
|
||||
@then("the expired log file should be identified")
|
||||
def step_expired_log_found(context: Context) -> None:
|
||||
assert len(context.expired_logs) >= 1, (
|
||||
f"Expected >= 1 expired log, found {len(context.expired_logs)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the log file should not be identified")
|
||||
def step_log_not_found(context: Context) -> None:
|
||||
assert len(context.expired_logs) == 0, (
|
||||
f"Expected 0 expired logs, found {len(context.expired_logs)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the log file should be removed")
|
||||
def step_log_removed(context: Context) -> None:
|
||||
assert not context.log_file.exists(), f"Log file {context.log_file} still exists"
|
||||
|
||||
|
||||
# ── Backup cleanup scenarios ──────────────────────────────────────
|
||||
|
||||
|
||||
@given("a backup file older than {days:d} days")
|
||||
def step_old_backup(context: Context, days: int) -> None:
|
||||
context.settings = _make_settings(
|
||||
cleanup_backup_retention_days=days,
|
||||
data_dir=Path(tempfile.mkdtemp(prefix="ca-data-test-")),
|
||||
)
|
||||
backup_dir = context.settings.data_dir / "backups"
|
||||
backup_dir.mkdir(parents=True)
|
||||
_register_cleanup(context, context.settings.data_dir)
|
||||
context.backup_file = backup_dir / "backup-20250101.tar.gz"
|
||||
context.backup_file.write_text("backup data")
|
||||
old_time = time.time() - ((days + 5) * 86400)
|
||||
os.utime(context.backup_file, (old_time, old_time))
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
@when("I scan for expired backups")
|
||||
def step_scan_backups(context: Context) -> None:
|
||||
backup_dir = context.service._get_backup_dir()
|
||||
context.expired_backups = context.service.scan_expired_files(
|
||||
backup_dir,
|
||||
context.settings.cleanup_backup_retention_days,
|
||||
pattern="*",
|
||||
)
|
||||
|
||||
|
||||
@then("the expired backup should be identified")
|
||||
def step_expired_backup_found(context: Context) -> None:
|
||||
assert len(context.expired_backups) >= 1, (
|
||||
f"Expected >= 1 expired backup, found {len(context.expired_backups)}"
|
||||
)
|
||||
|
||||
|
||||
# ── Dry-run mode ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I run cleanup in dry-run mode")
|
||||
def step_dry_run(context: Context) -> None:
|
||||
context.report = context.service.scan()
|
||||
|
||||
|
||||
@then("the dry-run report should show {count:d} sandbox to clean")
|
||||
def step_dry_run_sandboxes(context: Context, count: int) -> None:
|
||||
sandbox_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
||||
]
|
||||
assert len(sandbox_items) == count, (
|
||||
f"Expected {count} sandbox items, found {len(sandbox_items)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the dry-run report should show {count:d} session to clean")
|
||||
def step_dry_run_sessions(context: Context, count: int) -> None:
|
||||
# Sessions are identified externally; we track via inactive_sessions
|
||||
inactive = getattr(context, "inactive_sessions", None)
|
||||
if inactive is None:
|
||||
sessions = getattr(context, "test_sessions", [])
|
||||
inactive = context.service.scan_inactive_sessions(sessions)
|
||||
assert len(inactive) == count, (
|
||||
f"Expected {count} session items, found {len(inactive)}"
|
||||
)
|
||||
|
||||
|
||||
@then("no files should be deleted")
|
||||
def step_no_files_deleted(context: Context) -> None:
|
||||
# In dry-run, the sandbox_dir should still exist
|
||||
if hasattr(context, "sandbox_dir"):
|
||||
assert context.sandbox_dir.exists(), "Sandbox was deleted in dry-run mode"
|
||||
|
||||
|
||||
@then("the dry-run report should include the sandbox path")
|
||||
def step_dry_run_path(context: Context) -> None:
|
||||
paths = [i.path for i in context.report.stale_items]
|
||||
assert str(context.sandbox_dir) in paths, (
|
||||
f"Expected {context.sandbox_dir} in stale paths, got {paths}"
|
||||
)
|
||||
|
||||
|
||||
# ── Full purge mode ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I run cleanup with purge all")
|
||||
def step_purge_all(context: Context) -> None:
|
||||
context.report = context.service.purge(purge_all=True)
|
||||
# For session cleanup, simulate removal count
|
||||
if hasattr(context, "test_sessions"):
|
||||
inactive = context.service.scan_inactive_sessions(context.test_sessions)
|
||||
context.report.sessions.removed += len(inactive)
|
||||
|
||||
|
||||
# ── Per-resource summary ──────────────────────────────────────────
|
||||
|
||||
|
||||
@then("the cleanup summary should report 0 sandboxes removed")
|
||||
def step_zero_sandboxes(context: Context) -> None:
|
||||
assert context.report.sandboxes.removed == 0
|
||||
|
||||
|
||||
@then("the cleanup summary should report 0 sessions removed")
|
||||
def step_zero_sessions(context: Context) -> None:
|
||||
assert context.report.sessions.removed == 0
|
||||
|
||||
|
||||
@then("the cleanup summary should report 0 checkpoints removed")
|
||||
def step_zero_checkpoints(context: Context) -> None:
|
||||
assert context.report.checkpoints.removed == 0
|
||||
|
||||
|
||||
# ── Custom config ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("cleanup settings with sandbox max age {hours:d} hours")
|
||||
def step_custom_settings(context: Context, hours: int) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=hours)
|
||||
|
||||
|
||||
# ── Edge cases ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("the sandbox temp directory does not exist")
|
||||
def step_missing_temp(context: Context) -> None:
|
||||
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
# Patch _get_sandbox_dirs to return empty (non-existent temp)
|
||||
context.service._get_sandbox_dirs = lambda: []
|
||||
|
||||
|
||||
@then("zero stale sandboxes should be found")
|
||||
def step_zero_stale(context: Context) -> None:
|
||||
sandbox_items = [
|
||||
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
||||
]
|
||||
assert len(sandbox_items) == 0, f"Expected 0 stale, found {len(sandbox_items)}"
|
||||
|
||||
|
||||
@given("an empty database with no sessions or plans")
|
||||
def step_empty_db(context: Context) -> None:
|
||||
context.settings = _make_settings(cleanup_session_inactivity_days=30)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
context.test_sessions = []
|
||||
|
||||
|
||||
@then("zero inactive sessions should be found")
|
||||
def step_zero_sessions_found(context: Context) -> None:
|
||||
assert len(context.inactive_sessions) == 0, (
|
||||
f"Expected 0 inactive, found {len(context.inactive_sessions)}"
|
||||
)
|
||||
|
||||
|
||||
# ── Shared steps that work across scenarios ───────────────────────
|
||||
|
||||
|
||||
@given("no stale resources exist")
|
||||
def step_no_stale(context: Context) -> None:
|
||||
context.settings = _make_settings(
|
||||
cleanup_sandbox_max_age_hours=48,
|
||||
cleanup_session_inactivity_days=30,
|
||||
cleanup_checkpoint_max_per_plan=50,
|
||||
)
|
||||
context.service = CleanupService(
|
||||
settings=context.settings,
|
||||
active_plan_ids=frozenset(),
|
||||
)
|
||||
# Ensure no temp sandbox dirs match (patch to empty)
|
||||
context.service._get_sandbox_dirs = lambda: []
|
||||
Reference in New Issue
Block a user