# Implementation Plan: REQ-005 — Startup Version Check

## Domain Research

- **Problem type:** Integration (HTTP API via existing module, timestamp-based cache, version comparison)
- **Approaches evaluated:**
  1. **Synchronous call with cache** — On startup, check timestamp in config.json. If stale (>24h), call `fetch_latest_version()` synchronously (blocking). If fresh, skip network and use cached result. The `fetch_latest_version()` already has a 10s httpx timeout, which bounds worst-case delay.
  2. **Background thread** — Fire a thread for the version check, never join it in startup. Non-blocking, but the notification cannot be printed until the thread completes, meaning the notification would appear at an unpredictable point during startup.
  3. **Cached-only path with lazy refresh** — Use only cached data for the notification. If cache is stale, fire the network call but do not wait for it; update cache for *next* launch. Zero delay, but user never sees a notification on the first stale launch.
- **Recommended approach:** Option 1 — synchronous call with cache. Rationale:
  - The 24h cache means the network call happens at most once per day. 99%+ of startups are zero-network.
  - When the call does happen, `fetch_latest_version()` has a 10s timeout, which is an acceptable worst-case delay for a once-per-day operation.
  - The notification is deterministic: if a new version exists, the user sees it on the same launch that discovered it.
  - Option 2 (thread) adds concurrency complexity for a marginal benefit (saving ~0.2s on a once-per-day call).
  - Option 3 (lazy) means users never see the notification when their cache becomes stale, which defeats the purpose.
  - The spec says "a non-blocking approach (thread or cached-only path) is preferred but implementation detail is for Architect to decide". The cache IS the non-blocking mechanism. The actual network call is bounded and rare.
- **Libraries/tools:**
  - `packaging.version.Version` — for correct semver comparison. Available as transitive dependency of pip/setuptools. Already present in the venv. No new dependency needed.
  - `datetime` (stdlib) — for ISO 8601 timestamp parsing and 24h delta comparison.
  - `ievo.core.updater.fetch_latest_version` (from REQ-004) — reused, not duplicated.
  - `ievo.core.global_config.get_config` / `set_config` — for cache read/write. Already supports arbitrary keys.
- **Known pitfalls:**
  - `datetime.fromisoformat()` fails on some edge-case ISO 8601 formats in Python < 3.11. We require Python 3.13+, so this is safe.
  - `packaging.version.Version` raises `InvalidVersion` on malformed strings. Must handle this for cached values that could be corrupted.
  - `fetch_latest_version()` is `async`. Must bridge with `asyncio.run()` in the sync `ensure_startup()` context.
  - `asyncio.run()` fails if called inside an already-running event loop. However, `ensure_startup()` runs from the sync Typer callback, so no event loop exists at call time. Safe.

## Architecture Assessment

- **Fits existing architecture:** Yes. This adds logic to an existing function (`ensure_startup()`) and reuses existing infrastructure (`global_config`, `updater`).
- **New patterns introduced:** None. Uses existing patterns: `get_config`/`set_config` for persistent state, `asyncio.run()` for async bridging, `info()` for user notification.
- **Modules affected:**
  - `src/ievo/core/startup.py` — primary change (add version check logic)
  - `tests/test_startup.py` — extend with new test cases
- **Risk level:** Low. Additive change to one function. No shared interfaces modified.
- **Estimated complexity:** S (small)

## Pre-conditions

- [ ] REQ-004 must be implemented — provides `src/ievo/core/updater.py` with `fetch_latest_version()` and `get_current_version()`
- [ ] `src/ievo/core/global_config.py` must support arbitrary keys in `get_config`/`set_config` — confirmed: it uses `dict.get(key)` and `dict[key] = value` with no key whitelist

## Micro-Steps

### Step 1: Add `check_version_notification()` to `src/ievo/core/startup.py` with tests

Create a pure function that encapsulates the entire version check logic. This function:
1. Reads `version_check_last_run` and `version_check_result` from config.
2. If cache is fresh (< 24h), uses cached result. No network call.
3. If cache is stale or absent, calls `fetch_latest_version()` via `asyncio.run()`. On success, updates both cache keys. On failure (any exception from `fetch_latest_version()`), does nothing — no cache update, no error output.
4. Compares the resolved latest version against installed version using `packaging.version.Version`.
5. If latest > installed, calls `info(...)` with the notification message.
6. If latest <= installed or latest is `None`, does nothing.

**Function signature:**
```python
def check_version_notification(home: Path) -> None:
    """Check for a newer ievo version and print a notification if available.

    Uses a 24h cache to avoid hitting PyPI on every launch.
    Silent on failure — never blocks startup with errors.
    """
```

**Internal constants:**
```python
_VERSION_CHECK_INTERVAL = timedelta(hours=24)
```

**Tests to write** (in `tests/test_startup.py`):

1. `test_version_check_new_version_available` — Set cache to stale (>24h ago). Mock `fetch_latest_version()` to return `"0.2.0"`. Mock `ievo.__version__` as `"0.1.0"`. Assert `info()` is called with message containing `"v0.2.0"` and `"ievo update"`. Assert `version_check_last_run` and `version_check_result` are updated in config.json.
   **Covers AC-1, AC-3.**

2. `test_version_check_already_up_to_date` — Set cache to stale. Mock `fetch_latest_version()` to return `"0.1.0"`. Mock `ievo.__version__` as `"0.1.0"`. Assert `info()` is NOT called. Assert cache is updated.
   **Covers AC-4.**

3. `test_version_check_cache_fresh_with_newer_version` — Set `version_check_last_run` to 1 hour ago, `version_check_result` to `"0.2.0"`. Mock `ievo.__version__` as `"0.1.0"`. Assert `fetch_latest_version()` is NOT called (zero network). Assert `info()` IS called (notification from cache).
   **Covers AC-2.**

4. `test_version_check_cache_fresh_up_to_date` — Set `version_check_last_run` to 1 hour ago, `version_check_result` to `"0.1.0"`. Mock `ievo.__version__` as `"0.1.0"`. Assert `fetch_latest_version()` is NOT called. Assert `info()` is NOT called.
   **Covers AC-2, AC-4.**

5. `test_version_check_network_failure` — Set cache to stale. Mock `fetch_latest_version()` to raise `Exception`. Assert `info()` is NOT called. Assert `version_check_last_run` is NOT updated. Assert `version_check_result` is NOT updated.
   **Covers AC-5.**

6. `test_version_check_no_cache_first_run` — No `version_check_last_run` in config (first ever run). Mock `fetch_latest_version()` to return `"0.2.0"`. Assert network call IS made. Assert notification IS shown. Assert cache IS populated.
   **Covers AC-1 (no-cache variant).**

7. `test_version_check_malformed_cache_timestamp` — Set `version_check_last_run` to `"not-a-date"`. Mock `fetch_latest_version()` to return `"0.2.0"`. Assert the function treats it as stale (makes the call), does not crash.
   **Edge case.**

8. `test_version_check_malformed_cached_version` — Set `version_check_last_run` to 1 hour ago, `version_check_result` to `"not-a-version"`. Assert the function does not crash, does not print a notification (invalid version comparison is handled gracefully).
   **Edge case.**

- **Criteria covered:** AC-1, AC-2, AC-3, AC-4, AC-5
- **Files:** modify `src/ievo/core/startup.py`, modify `tests/test_startup.py`
- **Commit:** `REQ-005: step 1 — version check with 24h cache and tests`

### Step 2: Wire `check_version_notification()` into `ensure_startup()` and test ordering

Call `check_version_notification(home)` as the LAST step in `ensure_startup()`, after `init_sentry(home)`.

**Change to `ensure_startup()`:**
```python
def ensure_startup(home: Path) -> None:
    # ... existing auth check ...
    # ... existing telemetry check ...
    # Initialize Sentry (no-op if disabled or already initialized)
    init_sentry(home)

    # Version check (last step — after auth and telemetry)
    check_version_notification(home)
```

**Tests to write** (in `tests/test_startup.py`):

9. `test_ensure_startup_calls_version_check_last` — Verify ordering: mock all steps (auth, telemetry, sentry, version check) with a `MagicMock` that records call order. Assert `check_version_notification` is called AFTER `init_sentry`. Use `unittest.mock.call_args_list` or a shared call-order list.
   **Covers AC-6.**

10. `test_ensure_startup_version_check_does_not_block_on_error` — Set up a fully configured home (auth + telemetry present). Mock `check_version_notification` to raise an unexpected exception. Assert `ensure_startup()` does NOT propagate the exception (wraps in try/except). This ensures startup is truly non-blocking.
    **Covers negative AC: does not block startup.**

**Important safety detail:** Wrap the `check_version_notification(home)` call inside `ensure_startup()` in a bare `try/except Exception` that silently swallows errors. This is the final safety net — even if the version check function has an unhandled bug, startup must never fail because of it.

```python
    # Version check — last step, never blocks startup
    try:
        check_version_notification(home)
    except Exception:  # noqa: BEP001 — startup must never fail on version check
        pass
```

- **Criteria covered:** AC-6, negative AC (non-blocking)
- **Files:** modify `src/ievo/core/startup.py`, modify `tests/test_startup.py`
- **Commit:** `REQ-005: step 2 — wire version check into ensure_startup`

### Step 3: Verify existing startup tests still pass, add integration test

Ensure no regressions in the 4 existing startup tests. The existing tests mock `init_sentry` but do not account for the new `check_version_notification` call. They need updating to also mock or accommodate the version check.

**Changes to existing tests:**
- `test_ensure_startup_first_run` — Add mock for `check_version_notification` (or provide version cache state so it's a no-op).
- `test_ensure_startup_already_configured` — Same.
- `test_ensure_startup_claude_configured_telemetry_not` — Same.
- `test_ensure_startup_telemetry_configured_claude_not` — Same.

**New integration test:**

11. `test_version_check_end_to_end` — Full integration: create `config.json` with `claude_auth` and `telemetry` set (to skip prompts). No version cache. Mock only `fetch_latest_version()` (true external boundary) to return `"0.2.0"`. Patch `ievo.__version__` to `"0.1.0"`. Call `ensure_startup(tmp_path)`. Assert: (a) notification was printed, (b) `config.json` now contains `version_check_last_run` and `version_check_result` keys, (c) `version_check_result` equals `"0.2.0"`.
    **Covers AC-1, AC-3 at integration level.**

- **Criteria covered:** AC-1, AC-3 (integration), plus regression guard
- **Files:** modify `tests/test_startup.py`
- **Commit:** `REQ-005: step 3 — update existing tests, add integration test`

### Step 4: Verify notification message format and edge cases

Add tests that verify the exact message format and cover remaining edge cases.

**Tests to write:**

12. `test_version_check_message_format` — Assert the notification message is exactly: `A new version of ievo is available: v0.2.0. Run 'ievo update' to upgrade.` — verify no extra text, no installed version shown.
    **Covers negative AC: does not display installed version.**

13. `test_version_check_does_not_auto_update` — Assert that `subprocess` is never imported or called by `check_version_notification`. The function only prints a notification, never runs an upgrade.
    **Covers negative AC: does not auto-update.**

14. `test_version_check_cache_exactly_24h` — Set `version_check_last_run` to exactly 24 hours ago. Assert the network call IS made (boundary: >= 24h = stale).
    **Edge case: boundary condition.**

15. `test_version_check_cache_23h59m` — Set `version_check_last_run` to 23h59m ago. Assert the network call is NOT made (still fresh).
    **Edge case: boundary condition.**

- **Criteria covered:** Negative ACs, edge cases
- **Files:** modify `tests/test_startup.py`
- **Commit:** `REQ-005: step 4 — message format and edge case tests`

## Traceability

| Acceptance Criterion | Covered in Step |
|---------------------|-----------------|
| AC-1: Stale/missing cache -> calls fetch, stores result + timestamp | Step 1 (tests 1, 6), Step 3 (test 11) |
| AC-2: Fresh cache (< 24h) -> no network call, uses cached result | Step 1 (tests 3, 4) |
| AC-3: Latest > installed -> prints info() with new version + 'ievo update' | Step 1 (test 1), Step 3 (test 11), Step 4 (test 12) |
| AC-4: Installed == latest -> no version output | Step 1 (tests 2, 4) |
| AC-5: fetch returns None / raises -> no error, no cache update | Step 1 (test 5) |
| AC-6: Version check runs AFTER auth + telemetry + sentry | Step 2 (test 9) |
| Negative: does not block startup | Step 2 (test 10) |
| Negative: does not auto-update | Step 4 (test 13) |
| Negative: does not show installed version | Step 4 (test 12) |
| Negative: does not check > 1x per 24h | Step 1 (tests 3, 4), Step 4 (tests 14, 15) |
| Negative: does not require project directory | All tests use tmp_path as home, no manifest.yaml needed |
| Negative: does not show error on network failure | Step 1 (test 5) |

## Scope Guard

- **Considered but excluded:**
  - Background thread for non-blocking check — rejected. Cache provides non-blocking behavior. Thread adds complexity for ~0.2s savings on a once-per-day call.
  - User-configurable check interval — spec says hardcoded 24h. Not configurable.
  - `ievo config set version_check_interval` — not in spec.
  - `--no-update-check` CLI flag — not in spec. Can be a future REQ.
  - Calling `ensure_startup()` from subcommand paths — currently only called on no-args TUI path. The spec says "hook point: ensure_startup(), last step" which is what we implement. If startup should also run on subcommands, that's a separate concern.
  - Pre-release version handling — `packaging.version.Version` handles this correctly by default (pre-releases sort below releases), but we don't need special logic for it.
  - Showing notification only once per new version (de-duplicate across launches) — not in spec. The spec shows notification on every launch where latest > installed and cache is fresh.

## Architecture Notes for Coder

1. **Create `check_version_notification()` as a standalone function in `startup.py`.** Do NOT create a new module. The function is startup-specific and small (~30 lines). Keep it in the same file as `ensure_startup()`.

2. **Import `fetch_latest_version` and `get_current_version` from `ievo.core.updater` inside the function body** (lazy import). This avoids import-time dependency on the updater module, which only exists after REQ-004 is implemented. Pattern: same as existing lazy imports in `ensure_startup()` (e.g., `from ievo.core.claude_auth import prompt_claude_auth`).

3. **Use `asyncio.run()` to bridge the async `fetch_latest_version()`.** This is the same pattern already used in other command files (e.g., `run.py`). Safe because `ensure_startup()` runs from the sync Typer callback with no existing event loop.

4. **Version comparison must use `packaging.version.Version`.** Do NOT use string comparison (`"0.2.0" > "0.1.0"`) because it fails for multi-digit segments (`"0.10.0" < "0.9.0"` as strings). Import: `from packaging.version import Version, InvalidVersion`. Wrap comparison in try/except `InvalidVersion` for safety.

5. **The notification message must be exactly:** `A new version of ievo is available: v{latest}. Run 'ievo update' to upgrade.` — Use `info()` from `ievo.utils.console`. The `v` prefix is part of the display format, not the version string.

6. **Cache key names are exact strings:** `"version_check_last_run"` and `"version_check_result"`. Use `get_config(home, "version_check_last_run")` and `set_config(home, "version_check_last_run", value)`.

7. **Timestamp format:** Use `datetime.now(tz=timezone.utc).isoformat()` when writing. Use `datetime.fromisoformat()` when reading. Python 3.13 handles ISO 8601 with timezone info correctly.

8. **The try/except in `ensure_startup()` wrapping `check_version_notification(home)` is mandatory.** This is the non-blocking safety net. Even if the version check has an unhandled bug (malformed JSON, unexpected type from config, etc.), startup must proceed.

9. **Mock boundaries in tests:**
   - `fetch_latest_version()` — mock at `ievo.core.startup.fetch_latest_version` (after lazy import). Since the import is lazy (inside function body), mock at the updater module: `ievo.core.updater.fetch_latest_version`. OR, patch the function after it's imported within the function. The cleanest approach: in tests, patch `ievo.core.updater.fetch_latest_version` since that's the canonical location. The lazy import inside `check_version_notification()` will resolve to the patched version.
   - `ievo.__version__` — use `monkeypatch.setattr` on `ievo.version` module: `monkeypatch.setattr("ievo.version.__version__", "0.1.0")` and also `monkeypatch.setattr("ievo.__version__", "0.1.0")`.
   - `info()` — capture output using `capsys` or mock `ievo.utils.console.info`. Since `info()` goes through Rich `Console`, `capsys` may not capture it. Mock `ievo.core.startup.info` after import, or better: check `config.json` for side effects (cache written) and mock `console.print` at `ievo.utils.console.console.print`.
   - Config state: use real `config.json` files in `tmp_path`. Write real JSON. Do NOT mock `get_config`/`set_config` — per D-013, satisfy guard conditions with real state.

10. **Existing test updates (Step 3):** The 4 existing tests mock `init_sentry` via `patch("ievo.core.startup.init_sentry")`. After the change, `check_version_notification` will also be called. The cleanest fix: in these tests, also set the version cache in config.json to be fresh (< 24h) with `version_check_result` equal to the installed version. This way `check_version_notification` runs but produces no output and no network call, without needing an additional mock. This follows D-013: satisfy the guard with real state.

11. **Do NOT add `packaging` to `pyproject.toml` dependencies.** It is already available as a transitive dependency of pip/setuptools/hatchling. If the Coder wants to be explicit, they can add it, but it is not required.
