Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: XNLLLLH <XNLLLLH@users.noreply.github.com>
422 lines
14 KiB
Python
422 lines
14 KiB
Python
"""Phase 10.1 Plan 10.1-01 Task 1.5 -- `iai-mcp lifecycle status` CLI tests.
|
|
|
|
Covers status output for each of the 4 states, default WAKE when the
|
|
file is absent, and the formatted lines for sleep_cycle_progress and
|
|
quarantine.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from iai_mcp.lifecycle_state import (
|
|
LifecycleState,
|
|
LifecycleStateRecord,
|
|
save_state,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper -- patch LIFECYCLE_STATE_PATH to a tmp file for each test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _run_status(tmp_path, monkeypatch, capsys, record: LifecycleStateRecord | None):
|
|
"""Patch the module-level path constant, optionally seed a record,
|
|
invoke the subcommand directly, return captured stdout.
|
|
"""
|
|
target = tmp_path / "lifecycle_state.json"
|
|
monkeypatch.setattr(
|
|
"iai_mcp.lifecycle_state.LIFECYCLE_STATE_PATH",
|
|
target,
|
|
)
|
|
if record is not None:
|
|
save_state(record, target)
|
|
|
|
# Late import of cmd_lifecycle_status so the monkeypatch above
|
|
# applies before the function reads LIFECYCLE_STATE_PATH.
|
|
from iai_mcp.cli import cmd_lifecycle_status
|
|
|
|
args = argparse.Namespace()
|
|
rc = cmd_lifecycle_status(args)
|
|
out = capsys.readouterr().out
|
|
return rc, out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status output for each of the 4 states
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("state", list(LifecycleState))
|
|
def test_status_prints_state_label(tmp_path, monkeypatch, capsys, state):
|
|
record: LifecycleStateRecord = {
|
|
"current_state": state.value,
|
|
"since_ts": "2026-05-02T15:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T15:11:30+00:00",
|
|
"wrapper_event_seq": 42,
|
|
"sleep_cycle_progress": None,
|
|
"quarantine": None,
|
|
"shadow_run": True,
|
|
}
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record)
|
|
assert rc == 0
|
|
assert f"state: {state.value}" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Absent file -> default WAKE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_status_returns_default_wake_when_file_absent(tmp_path, monkeypatch, capsys):
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record=None)
|
|
assert rc == 0
|
|
assert "state: WAKE" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wrapper-event seq + last_activity rendered
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_status_renders_seq_and_last_activity(tmp_path, monkeypatch, capsys):
|
|
record: LifecycleStateRecord = {
|
|
"current_state": "WAKE",
|
|
"since_ts": "2026-05-02T15:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T15:11:30+00:00",
|
|
"wrapper_event_seq": 137,
|
|
"sleep_cycle_progress": None,
|
|
"quarantine": None,
|
|
"shadow_run": True,
|
|
}
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record)
|
|
assert rc == 0
|
|
assert "wrapper_event_seq: 137" in out
|
|
assert "last_activity: 2026-05-02T15:11:30+00:00" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sleep_cycle_progress rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_status_progress_none_says_none(tmp_path, monkeypatch, capsys):
|
|
record: LifecycleStateRecord = {
|
|
"current_state": "WAKE",
|
|
"since_ts": "2026-05-02T15:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T15:00:00+00:00",
|
|
"wrapper_event_seq": 0,
|
|
"sleep_cycle_progress": None,
|
|
"quarantine": None,
|
|
"shadow_run": True,
|
|
}
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record)
|
|
assert rc == 0
|
|
assert "sleep_cycle_progress: none" in out
|
|
|
|
|
|
def test_status_progress_active_renders_step_attempt(tmp_path, monkeypatch, capsys):
|
|
record: LifecycleStateRecord = {
|
|
"current_state": "SLEEP",
|
|
"since_ts": "2026-05-02T03:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T03:00:00+00:00",
|
|
"wrapper_event_seq": 7,
|
|
"sleep_cycle_progress": {
|
|
"last_completed_step": 3,
|
|
"attempt": 1,
|
|
"last_error": None,
|
|
"started_at": "2026-05-02T03:00:00+00:00",
|
|
},
|
|
"quarantine": None,
|
|
"shadow_run": True,
|
|
}
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record)
|
|
assert rc == 0
|
|
assert "step=3" in out
|
|
assert "attempt=1" in out
|
|
assert "last_error=none" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Quarantine rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_status_quarantine_none_says_none(tmp_path, monkeypatch, capsys):
|
|
record: LifecycleStateRecord = {
|
|
"current_state": "WAKE",
|
|
"since_ts": "2026-05-02T15:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T15:00:00+00:00",
|
|
"wrapper_event_seq": 0,
|
|
"sleep_cycle_progress": None,
|
|
"quarantine": None,
|
|
"shadow_run": True,
|
|
}
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record)
|
|
assert rc == 0
|
|
assert "quarantine: none" in out
|
|
|
|
|
|
def test_status_quarantine_active_renders_until_and_reason(tmp_path, monkeypatch, capsys):
|
|
record: LifecycleStateRecord = {
|
|
"current_state": "SLEEP",
|
|
"since_ts": "2026-05-02T03:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T03:00:00+00:00",
|
|
"wrapper_event_seq": 1,
|
|
"sleep_cycle_progress": None,
|
|
"quarantine": {
|
|
"until_ts": "2026-05-03T03:00:00+00:00",
|
|
"reason": "sleep step 4 failed 3x",
|
|
"since_ts": "2026-05-02T03:00:00+00:00",
|
|
},
|
|
"shadow_run": True,
|
|
}
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record)
|
|
assert rc == 0
|
|
assert "until=2026-05-03T03:00:00+00:00" in out
|
|
assert "reason=sleep step 4 failed 3x" in out
|
|
assert "since=2026-05-02T03:00:00+00:00" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# shadow_run flag rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_status_shadow_run_true_mentions_legacy_watchdog(tmp_path, monkeypatch, capsys):
|
|
record: LifecycleStateRecord = {
|
|
"current_state": "WAKE",
|
|
"since_ts": "2026-05-02T15:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T15:00:00+00:00",
|
|
"wrapper_event_seq": 0,
|
|
"sleep_cycle_progress": None,
|
|
"quarantine": None,
|
|
"shadow_run": True,
|
|
}
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record)
|
|
assert rc == 0
|
|
assert "shadow_run: true" in out
|
|
assert "Phase 10.6" in out # spec line mentions phase that flips it
|
|
|
|
|
|
def test_status_shadow_run_false(tmp_path, monkeypatch, capsys):
|
|
record: LifecycleStateRecord = {
|
|
"current_state": "WAKE",
|
|
"since_ts": "2026-05-02T15:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T15:00:00+00:00",
|
|
"wrapper_event_seq": 0,
|
|
"sleep_cycle_progress": None,
|
|
"quarantine": None,
|
|
"shadow_run": False,
|
|
}
|
|
rc, out = _run_status(tmp_path, monkeypatch, capsys, record)
|
|
assert rc == 0
|
|
assert "shadow_run: false" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper formatter sanity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_format_relative_minutes(tmp_path, monkeypatch):
|
|
from iai_mcp.cli import _format_relative
|
|
|
|
now = datetime(2026, 5, 2, 15, 12, 0, tzinfo=timezone.utc)
|
|
out = _format_relative("2026-05-02T15:00:00+00:00", now=now)
|
|
assert out == "12 minutes"
|
|
|
|
|
|
def test_format_relative_hours():
|
|
from iai_mcp.cli import _format_relative
|
|
|
|
now = datetime(2026, 5, 2, 15, 12, 0, tzinfo=timezone.utc)
|
|
out = _format_relative("2026-05-02T13:12:00+00:00", now=now)
|
|
assert out == "2 hours"
|
|
|
|
|
|
def test_format_relative_days():
|
|
from iai_mcp.cli import _format_relative
|
|
|
|
now = datetime(2026, 5, 5, 0, 0, 0, tzinfo=timezone.utc)
|
|
out = _format_relative("2026-05-02T00:00:00+00:00", now=now)
|
|
assert out == "3 days"
|
|
|
|
|
|
def test_format_relative_singular_minute():
|
|
from iai_mcp.cli import _format_relative
|
|
|
|
now = datetime(2026, 5, 2, 15, 1, 0, tzinfo=timezone.utc)
|
|
out = _format_relative("2026-05-02T15:00:00+00:00", now=now)
|
|
assert out == "1 minute"
|
|
|
|
|
|
def test_format_relative_handles_garbage():
|
|
from iai_mcp.cli import _format_relative
|
|
|
|
assert _format_relative("not-a-timestamp") == "unknown"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: invoke via main([...])
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_cli_main_lifecycle_status_via_main(tmp_path, monkeypatch, capsys):
|
|
target = tmp_path / "lifecycle_state.json"
|
|
monkeypatch.setattr(
|
|
"iai_mcp.lifecycle_state.LIFECYCLE_STATE_PATH",
|
|
target,
|
|
)
|
|
record: LifecycleStateRecord = {
|
|
"current_state": "DROWSY",
|
|
"since_ts": "2026-05-02T15:00:00+00:00",
|
|
"last_activity_ts": "2026-05-02T15:11:30+00:00",
|
|
"wrapper_event_seq": 42,
|
|
"sleep_cycle_progress": None,
|
|
"quarantine": None,
|
|
"shadow_run": True,
|
|
}
|
|
save_state(record, target)
|
|
|
|
from iai_mcp.cli import main
|
|
|
|
rc = main(["lifecycle", "status"])
|
|
out = capsys.readouterr().out
|
|
assert rc == 0
|
|
assert "state: DROWSY" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plan 10.6-01 Task 1.2 -- lifecycle force-unlock subcommand
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_force_unlock_with_yes_flag(tmp_path, monkeypatch, capsys):
|
|
"""``--yes`` skips the prompt and clears a present lockfile."""
|
|
import json as _json
|
|
|
|
from iai_mcp.cli import cmd_lifecycle_force_unlock
|
|
|
|
lock_path = tmp_path / ".locked"
|
|
lock_path.write_text(
|
|
_json.dumps(
|
|
{
|
|
"pid": 4242,
|
|
"hostname": "stale-host.local",
|
|
"started_at": "2026-04-29T08:00:00+00:00",
|
|
"schema_version": 1,
|
|
}
|
|
)
|
|
)
|
|
|
|
args = argparse.Namespace(yes=True, lock_path=lock_path)
|
|
rc = cmd_lifecycle_force_unlock(args)
|
|
out = capsys.readouterr().out
|
|
assert rc == 0
|
|
assert "pid=4242" in out
|
|
assert "stale-host.local" in out
|
|
assert "Lockfile removed." in out
|
|
assert not lock_path.exists()
|
|
|
|
|
|
def test_force_unlock_without_yes_prompts_no_aborts(
|
|
tmp_path, monkeypatch, capsys,
|
|
):
|
|
"""No ``--yes`` flag: prompt is read, "n" aborts with rc=1, file kept."""
|
|
import json as _json
|
|
|
|
from iai_mcp.cli import cmd_lifecycle_force_unlock
|
|
|
|
lock_path = tmp_path / ".locked"
|
|
lock_path.write_text(
|
|
_json.dumps(
|
|
{
|
|
"pid": 4242,
|
|
"hostname": "stale-host.local",
|
|
"started_at": "2026-04-29T08:00:00+00:00",
|
|
"schema_version": 1,
|
|
}
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr("builtins.input", lambda _prompt="": "n")
|
|
|
|
args = argparse.Namespace(yes=False, lock_path=lock_path)
|
|
rc = cmd_lifecycle_force_unlock(args)
|
|
captured = capsys.readouterr()
|
|
assert rc == 1
|
|
assert "cancelled" in captured.err.lower()
|
|
assert lock_path.exists()
|
|
|
|
|
|
def test_force_unlock_without_yes_prompts_y_succeeds(
|
|
tmp_path, monkeypatch, capsys,
|
|
):
|
|
"""Prompt receives "y" -> file cleared, rc=0."""
|
|
import json as _json
|
|
|
|
from iai_mcp.cli import cmd_lifecycle_force_unlock
|
|
|
|
lock_path = tmp_path / ".locked"
|
|
lock_path.write_text(
|
|
_json.dumps(
|
|
{
|
|
"pid": 4242,
|
|
"hostname": "stale-host.local",
|
|
"started_at": "2026-04-29T08:00:00+00:00",
|
|
"schema_version": 1,
|
|
}
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr("builtins.input", lambda _prompt="": "y")
|
|
|
|
args = argparse.Namespace(yes=False, lock_path=lock_path)
|
|
rc = cmd_lifecycle_force_unlock(args)
|
|
out = capsys.readouterr().out
|
|
assert rc == 0
|
|
assert "Lockfile removed." in out
|
|
assert not lock_path.exists()
|
|
|
|
|
|
def test_force_unlock_when_no_lockfile(tmp_path, capsys):
|
|
"""Absent lockfile -> rc=0 with "nothing to unlock" message."""
|
|
from iai_mcp.cli import cmd_lifecycle_force_unlock
|
|
|
|
lock_path = tmp_path / ".locked" # never created
|
|
args = argparse.Namespace(yes=True, lock_path=lock_path)
|
|
rc = cmd_lifecycle_force_unlock(args)
|
|
out = capsys.readouterr().out
|
|
assert rc == 0
|
|
assert "nothing to unlock" in out.lower()
|
|
|
|
|
|
def test_cli_main_lifecycle_force_unlock_via_main(
|
|
tmp_path, monkeypatch, capsys,
|
|
):
|
|
"""End-to-end: invoke via ``iai-mcp lifecycle force-unlock --yes``.
|
|
|
|
Production path uses ``DEFAULT_LOCK_PATH``; we monkey-patch it so
|
|
the test does not touch ``~/.iai-mcp/.locked``.
|
|
"""
|
|
import json as _json
|
|
|
|
lock_path = tmp_path / ".locked"
|
|
lock_path.write_text(
|
|
_json.dumps(
|
|
{
|
|
"pid": 9999,
|
|
"hostname": "foreign-host.local",
|
|
"started_at": "2026-04-30T10:00:00+00:00",
|
|
"schema_version": 1,
|
|
}
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"iai_mcp.lifecycle_lock.DEFAULT_LOCK_PATH",
|
|
lock_path,
|
|
)
|
|
|
|
from iai_mcp.cli import main
|
|
|
|
rc = main(["lifecycle", "force-unlock", "--yes"])
|
|
out = capsys.readouterr().out
|
|
assert rc == 0
|
|
assert "Lockfile removed." in out
|
|
assert not lock_path.exists()
|