diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4c6a8f..f3fb514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: git config --global user.name ci git config --global user.email ci@example.invalid - name: Install - run: uv sync --python 3.12 + run: uv sync --locked --python 3.12 - name: Tests run: uv run pytest -q - name: Licence allowlist diff --git a/.gitignore b/.gitignore index 51657e4..dda3719 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ uv.lock.bak .pytest_out config/ _site/ +/ion.yml diff --git a/README.md b/README.md index 60556bb..971d73a 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ deploy/install.sh ~/.ion/ion.yml launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.aska.ion.plist ``` -It runs `ion run` daily at 06:30 and logs to `~/.ion/ion.log`. The shipped plist runs executors as dry runs; add `--apply` after `run` once you trust the proposals. Keep the source outside `~/Documents` (macOS privacy prompts). +It runs `ion run` daily at 06:30 and logs to `~/.ion/ion.log`. The installer gives the agent the same state directory as your shell (`ION_HOME`, default `~/.ion`) and an absolute config path (the argument, else `ION_CONFIG`, else `/ion.yml`), so `ion pause` from your shell also stops the scheduled run. It refuses to install while state from before the rename is still in `~/.mimar`. The shipped plist runs executors as dry runs; add `--apply` after `run` once you trust the proposals. Keep the source outside `~/Documents` (macOS privacy prompts). Phase 2 (when cost or uptime justify it): a GitHub App identity and a GitHub Actions schedule with `llm.backend: anthropic_api`. Only config changes. diff --git a/deploy/ai.aska.ion.plist b/deploy/ai.aska.ion.plist index 934ea49..f7642b0 100644 --- a/deploy/ai.aska.ion.plist +++ b/deploy/ai.aska.ion.plist @@ -28,11 +28,13 @@ __HOME__/.local/bin:__HOME__/Library/Python/3.9/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin HOME __HOME__ + ION_HOME + __ION_HOME__ StandardOutPath - __HOME__/.ion/ion.log + __ION_HOME__/ion.log StandardErrorPath - __HOME__/.ion/ion.log + __ION_HOME__/ion.log RunAtLoad ProcessType diff --git a/deploy/install.sh b/deploy/install.sh index 48132a3..eca430a 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,28 +1,40 @@ #!/usr/bin/env bash # Installs the LaunchAgent plist for Ion. It does NOT load it; you decide when. # deploy/install.sh [path/to/config.yml] +# The agent gets the same state directory (ION_HOME, default ~/.ion) and config (the argument, else ION_CONFIG, else +# /ion.yml) as this shell, so `ion pause` here stops the scheduled run too. set -euo pipefail SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CONFIG="${1:-$HOME/.ion/ion.yml}" +STATE_DIR="${ION_HOME:-$HOME/.ion}" +CONFIG="${1:-${ION_CONFIG:-$STATE_DIR/ion.yml}}" UV="${UV:-$(command -v uv || echo "$HOME/.local/bin/uv")}" LABEL="ai.aska.ion" DEST="$HOME/Library/LaunchAgents/$LABEL.plist" +if [ -z "${ION_HOME:-}" ] && [ -f "$HOME/.mimar/state.db" ] && [ ! -f "$STATE_DIR/state.db" ]; then + echo "ion state is still in ~/.mimar (the old name). Move it first, or the agent starts from empty state" >&2 + echo "and ignores ~/.mimar/PAUSED: mkdir -p ~/.ion && mv ~/.mimar/* ~/.ion/" >&2 + exit 1 +fi [ -f "$CONFIG" ] || { echo "config not found: $CONFIG" >&2; exit 1; } [ -x "$UV" ] || { echo "uv not found (set UV=/path/to/uv)" >&2; exit 1; } +# launchd runs the agent in $SRC_DIR, so a relative path would name a different file there +CONFIG="$(cd "$(dirname "$CONFIG")" && pwd)/$(basename "$CONFIG")" +STATE_DIR="$(mkdir -p "$STATE_DIR" && cd "$STATE_DIR" && pwd)" -(cd "$SRC_DIR" && "$UV" sync --no-dev >/dev/null) +(cd "$SRC_DIR" && "$UV" sync --locked --no-dev >/dev/null) ION_BIN="$SRC_DIR/.venv/bin/ion" [ -x "$ION_BIN" ] || { echo "ion entry point missing at $ION_BIN" >&2; exit 1; } -mkdir -p "$HOME/.ion" "$HOME/Library/LaunchAgents" -sed -e "s|__ION_BIN__|$ION_BIN|g" -e "s|__CONFIG__|$CONFIG|g" \ - -e "s|__SRC_DIR__|$SRC_DIR|g" -e "s|__HOME__|$HOME|g" \ +mkdir -p "$HOME/Library/LaunchAgents" +esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; } +sed -e "s|__ION_BIN__|$(esc "$ION_BIN")|g" -e "s|__CONFIG__|$(esc "$CONFIG")|g" \ + -e "s|__SRC_DIR__|$(esc "$SRC_DIR")|g" -e "s|__ION_HOME__|$(esc "$STATE_DIR")|g" -e "s|__HOME__|$(esc "$HOME")|g" \ "$SRC_DIR/deploy/ai.aska.ion.plist" > "$DEST" plutil -lint "$DEST" echo "installed $DEST (not loaded)." -echo "check first: $ION_BIN --config $CONFIG doctor" +echo "check first: ION_HOME=$STATE_DIR $ION_BIN --config $CONFIG doctor" echo "to enable: launchctl bootstrap gui/$(id -u) $DEST" echo "to disable: launchctl bootout gui/$(id -u)/$LABEL" -echo "kill switch: $ION_BIN pause (and resume)" +echo "kill switch: ION_HOME=$STATE_DIR $ION_BIN pause (and resume)" diff --git a/ion/config.py b/ion/config.py index 377565f..36b83e3 100644 --- a/ion/config.py +++ b/ion/config.py @@ -131,14 +131,18 @@ def find_config_file() -> Path | None: env = os.environ.get("ION_CONFIG") or os.environ.get("MIMAR_CONFIG") if env: return Path(env).expanduser() - for c in (Path.cwd() / "ion.yml", home() / "ion.yml"): + unset = "ION_HOME" not in os.environ # then ~/.ion/ion.yml counts even while state is still in ~/.mimar + for c in (Path.cwd() / "ion.yml", home() / "ion.yml", *([Path("~/.ion/ion.yml").expanduser()] if unset else [])): if c.exists(): return c # Deprecation shim: this bot was called mimar until 2026-10-13. - for c in (Path.cwd() / "mimar.yml", Path("~/.mimar/mimar.yml").expanduser()): + legacy = Path("~/.mimar").expanduser() + for c in (Path.cwd() / "mimar.yml", legacy / "mimar.yml"): if c.exists() and "ION_HOME" not in os.environ: - print(f"ion: reading legacy {c}; migrate: mkdir -p ~/.ion && mv {c} ~/.ion/ion.yml (the name 'mimar' is deprecated)", - file=sys.stderr) + # move the state too: config alone would leave state.db and the PAUSED kill switch behind in ~/.mimar + steps = ["mkdir -p ~/.ion"] + (["mv ~/.mimar/* ~/.ion/"] if legacy.is_dir() else []) + steps.append(f"mv {'~/.ion/mimar.yml' if c.parent == legacy else c} ~/.ion/ion.yml") + print(f"ion: reading legacy {c}; migrate: {' && '.join(steps)} (the name 'mimar' is deprecated)", file=sys.stderr) return c return None diff --git a/ion/util.py b/ion/util.py index b64a23d..17ee95f 100644 --- a/ion/util.py +++ b/ion/util.py @@ -29,8 +29,8 @@ class Waiting(Exception): def home() -> Path: p = Path(os.environ.get("ION_HOME", "~/.ion")).expanduser() legacy = Path("~/.mimar").expanduser() - if "ION_HOME" not in os.environ and not p.exists() and legacy.is_dir(): - return legacy # deprecation shim: operator state not migrated yet + if "ION_HOME" not in os.environ and not (p / "state.db").exists() and legacy.is_dir(): + return legacy # deprecation shim: operator state not migrated yet (an empty ~/.ion does not count) p.mkdir(parents=True, exist_ok=True) return p diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..4be9411 --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,131 @@ +"""deploy/install.sh and the LaunchAgent it renders. + +Each test runs the real installer against a temp HOME (a stub `plutil`, and `UV=true` so nothing is synced), then runs +the rendered ProgramArguments the way launchd would: in WorkingDirectory, with only the plist's EnvironmentVariables. +`run` is swapped for `status`, which reads the same config, state dir and kill switch offline. +""" +import plistlib +import re +import shutil +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +ION_BIN = Path(sys.executable).parent / "ion" +TRUE = shutil.which("true") + +pytestmark = pytest.mark.skipif(not ION_BIN.exists() or not shutil.which("bash") or not TRUE, + reason="needs bash and the ion entry point next to the test interpreter") + + +def _src(tmp_path: Path) -> Path: + """The layout install.sh expects (deploy/ and .venv/bin/ion), in a temp dir rather than this checkout.""" + src = tmp_path / "src" + (src / "deploy").mkdir(parents=True) + for f in ("install.sh", "ai.aska.ion.plist"): + shutil.copy2(ROOT / "deploy" / f, src / "deploy" / f) + (src / ".venv" / "bin").mkdir(parents=True) + (src / ".venv" / "bin" / "ion").symlink_to(ION_BIN) + return src + + +def _install(tmp_path: Path, home: Path, config_arg: str, cwd: Path, env: dict | None = None) -> subprocess.CompletedProcess: + fake = tmp_path / "fakebin" + fake.mkdir(exist_ok=True) + (fake / "plutil").write_text('#!/bin/sh\necho "$2: OK"\n') + (fake / "plutil").chmod(0o755) + return subprocess.run(["bash", str(_src(tmp_path) / "deploy" / "install.sh"), config_arg], cwd=cwd, + env={"HOME": str(home), "PATH": f"{fake}:/usr/bin:/bin", "UV": TRUE, **(env or {})}, + capture_output=True, text=True) + + +def _plist(home: Path) -> dict: + raw = (home / "Library" / "LaunchAgents" / "ai.aska.ion.plist").read_bytes() + # the template's header comment contains `--apply`, which CoreFoundation accepts and expat does not + return plistlib.loads(re.sub(rb"", b"", raw, flags=re.S)) + + +def _as_launchd(plist: dict) -> subprocess.CompletedProcess: + argv = list(plist["ProgramArguments"]) + argv[argv.index("run")] = "status" + return subprocess.run(argv, cwd=plist["WorkingDirectory"], env=dict(plist["EnvironmentVariables"]), + capture_output=True, text=True) + + +def _shell(home: Path, *args, ion_home: Path | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess: + env = {"HOME": str(home), "PATH": "/usr/bin:/bin", **({"ION_HOME": str(ion_home)} if ion_home else {})} + return subprocess.run([str(ION_BIN), *args], env=env, cwd=cwd, capture_output=True, text=True) + + +def _legacy_state(home: Path) -> Path: + """An operator from before the rename: ~/.mimar holds the config, a proposal and an engaged kill switch.""" + legacy = home / ".mimar" + legacy.mkdir(parents=True) + (legacy / "mimar.yml").write_text("org: acme\n") + assert _shell(home, "--config", str(legacy / "mimar.yml"), "status").returncode == 0 # creates state.db + con = sqlite3.connect(legacy / "state.db") + con.execute("insert into proposals(id,num,kind,key,status,score,title,state) values" + "('P-0001',1,'archive','archive:old-tool','awaiting-approval',0.9,'archive old-tool','{}')") + con.commit() + con.close() + assert _shell(home, "pause").returncode == 0 + return legacy + + +def test_relative_config_path_is_the_file_the_operator_named(tmp_path): + """launchd runs the agent in the source tree, where a dev `ion.yml` may sit (README: `ion --config ion.yml doctor`).""" + home = tmp_path / "home" + (home / ".ion").mkdir(parents=True) + (home / ".ion" / "ion.yml").write_text("org: acme\n") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "ion.yml").write_text("org: dev-sandbox\n") + p = _install(tmp_path, home, "ion.yml", cwd=home / ".ion") + assert p.returncode == 0, p.stdout + p.stderr + assert "org: acme" in _as_launchd(_plist(home)).stdout + + +def test_operator_ion_home_reaches_the_launch_agent(tmp_path): + """`ion pause` from a shell with ION_HOME set must stop the scheduled run too.""" + home, state = tmp_path / "home", tmp_path / "ion-state" + state.mkdir() + (state / "ion.yml").write_text("org: acme\n") + p = _install(tmp_path, home, "", cwd=tmp_path, env={"ION_HOME": str(state)}) # config defaults to $ION_HOME + assert p.returncode == 0, p.stdout + p.stderr + assert _shell(home, "pause", ion_home=state).returncode == 0 + plist = _plist(home) + assert plist["StandardOutPath"] == str(state / "ion.log") + assert "kill switch: ON" in _as_launchd(plist).stdout + + +def test_install_refuses_to_abandon_unmigrated_mimar_state(tmp_path): + home = tmp_path / "home" + legacy = _legacy_state(home) + p = _install(tmp_path, home, str(legacy / "mimar.yml"), cwd=tmp_path) + assert p.returncode != 0 and "~/.mimar" in p.stderr + assert not (home / "Library" / "LaunchAgents" / "ai.aska.ion.plist").exists() + assert not (home / ".ion").exists() + + +def test_an_empty_ion_dir_does_not_hide_mimar_state(tmp_path): + """The old migration note said `mkdir -p ~/.ion && mv ~/.ion/ion.yml`; state.db and PAUSED stayed behind.""" + home = tmp_path / "home" + legacy = _legacy_state(home) + (home / ".ion").mkdir() + (legacy / "mimar.yml").rename(home / ".ion" / "ion.yml") + out = _shell(home, "status", cwd=tmp_path).stdout + assert "kill switch: ON" in out and "P-0001" in out + + +def test_following_the_printed_migration_note_keeps_state(tmp_path): + home = tmp_path / "home" + _legacy_state(home) + note = _shell(home, "status", cwd=tmp_path).stderr + cmd = re.search(r"migrate: (.*) \(the name", note).group(1) + assert subprocess.run(["bash", "-c", cmd], env={"HOME": str(home), "PATH": "/usr/bin:/bin"}).returncode == 0 + assert not (home / ".mimar" / "state.db").exists() + out = _shell(home, "status", cwd=tmp_path) + assert "kill switch: ON" in out.stdout and "P-0001" in out.stdout and "deprecated" not in out.stderr