Files
cyclone/docs/superpowers/plans/2026-06-24-cyclone-sftp-polling-enablement.md
T

44 KiB

SP25 — SFTP Polling Enablement Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the existing SP16 inbound-MFT scheduler point at mft.gainwelltechnologies.com and poll real files unattended, by extending the secrets lookup to support plain env vars, adding PATCH /api/clearhouse to flip stub without raw SQL, and writing an operator runbook.

Architecture: Three small backend changes layered on the existing SP16 polling scheduler — secrets.get_secret() gains an env-var-first lookup tier; scheduler.reconfigure_scheduler() cancels and replaces the running task with a 30-second drain; PATCH /api/clearhouse is a thin endpoint that validates → writes → re-configures → returns. A new docs/RUNBOOK.md documents the operator steps. No parser changes, no new migration, no UI.

Tech Stack: Python 3.11+, FastAPI, Pydantic v2, SQLAlchemy 2.x, asyncio, pytest. Backend-only; no frontend or build changes.

Branch: sp25-sftp-polling-enablement


File Structure

File Change Responsibility
backend/src/cyclone/secrets.py Modify Three-tier get_secret(): env var → Keychain → None.
backend/src/cyclone/scheduler.py Modify New reconfigure_scheduler() helper that cancels the running task and replaces the singleton.
backend/src/cyclone/store.py Modify New update_clearhouse() method on the store facade.
backend/src/cyclone/api.py Modify New PATCH /api/clearhouse endpoint gated by matrix_gate.
backend/tests/test_secrets_envvar.py Create 8 cases covering the three-tier lookup.
backend/tests/test_store_update_clearhouse.py Create 2 cases for the new store method.
backend/tests/test_scheduler_reconfigure.py Create 5 cases for the reconfigure helper.
backend/tests/test_api_clearhouse_patch.py Create 7 cases for the PATCH endpoint.
docs/RUNBOOK.md Create Operator runbook for SFTP polling enablement.

Task 1: Extend secrets.get_secret() with env-var lookup

Files:

  • Modify: backend/src/cyclone/secrets.py

  • Test: backend/tests/test_secrets_envvar.py

  • Step 1: Write the failing tests

Create backend/tests/test_secrets_envvar.py:

"""SP25 — env-var fallback in cyclone.secrets.get_secret().

The scheduler's ``SftpClient._connect`` calls ``get_secret(name)`` to
fetch the MFT password. Today the lookup is macOS-Keychain-only via
the ``keyring`` library. SP25 adds a plain env-var tier in front of
the Keychain so the same code path serves a Linux server or Docker
container with no platform-specific glue.
"""
from __future__ import annotations

import importlib
import os

import pytest


@pytest.fixture
def secrets_module(monkeypatch):
    """Reload cyclone.secrets with a clean keyring state per test."""
    # Force a fresh import so module-level keyring cache is clean.
    import cyclone.secrets as secrets_mod
    importlib.reload(secrets_mod)
    yield secrets_mod
    importlib.reload(secrets_mod)


def test_env_var_wins_over_keychain(monkeypatch, secrets_module):
    """Env var present AND Keychain has an entry → env var returned."""
    monkeypatch.setenv("cyclone.test.password", "from-env")
    monkeypatch.setattr(
        secrets_module.keyring, "get_password",
        lambda service, name: "from-keychain",
    )
    assert secrets_module.get_secret("cyclone.test.password") == "from-env"


def test_env_var_strips_trailing_newline(monkeypatch, secrets_module):
    """A trailing newline (common in .env files) is stripped."""
    monkeypatch.setenv("cyclone.test.password", "s3cret\n")
    assert secrets_module.get_secret("cyclone.test.password") == "s3cret"


def test_env_var_strips_leading_and_trailing_whitespace(monkeypatch, secrets_module):
    monkeypatch.setenv("cyclone.test.password", "  s3cret  \n")
    assert secrets_module.get_secret("cyclone.test.password") == "s3cret"


def test_env_var_set_keychain_absent(monkeypatch, secrets_module):
    monkeypatch.setenv("cyclone.test.password", "from-env")
    monkeypatch.setattr(
        secrets_module.keyring, "get_password", lambda service, name: None,
    )
    assert secrets_module.get_secret("cyclone.test.password") == "from-env"


def test_env_var_absent_keychain_present(monkeypatch, secrets_module):
    monkeypatch.delenv("cyclone.test.password", raising=False)
    monkeypatch.setattr(
        secrets_module.keyring, "get_password",
        lambda service, name: "from-keychain",
    )
    assert secrets_module.get_secret("cyclone.test.password") == "from-keychain"


def test_both_absent_returns_none(monkeypatch, secrets_module):
    monkeypatch.delenv("cyclone.test.password", raising=False)
    monkeypatch.setattr(
        secrets_module.keyring, "get_password", lambda service, name: None,
    )
    assert secrets_module.get_secret("cyclone.test.password") is None


def test_keyring_library_missing_falls_through_to_none(monkeypatch, secrets_module):
    """If keyring is not installed at all, get_secret still returns None
    instead of raising ImportError."""
    monkeypatch.setenv("cyclone.test.password", "from-env")
    monkeypatch.setattr(secrets_module, "_HAS_KEYRING", False)
    # Even if keyring is missing, the env-var tier still wins.
    assert secrets_module.get_secret("cyclone.test.password") == "from-env"


def test_empty_env_var_treated_as_absent(monkeypatch, secrets_module):
    """An env var set to '' should not propagate — fall through to Keychain/None."""
    monkeypatch.setenv("cyclone.test.password", "")
    monkeypatch.setattr(
        secrets_module.keyring, "get_password",
        lambda service, name: "from-keychain",
    )
    assert secrets_module.get_secret("cyclone.test.password") == "from-keychain"


def test_gainwell_env_var_mapping(monkeypatch, secrets_module):
    """The Keychain account ``sftp.gainwell.password`` maps to the env
    var ``CYCLONE_SFTP_PASSWORD`` via the internal name table. Without
    this, an operator setting ``CYCLONE_SFTP_PASSWORD`` on a Linux box
    would not get the MFT password."""
    monkeypatch.setenv("CYCLONE_SFTP_PASSWORD", "real-mft-password")
    monkeypatch.setattr(
        secrets_module.keyring, "get_password", lambda service, name: None,
    )
    assert secrets_module.get_secret("sftp.gainwell.password") == "real-mft-password"
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_secrets_envvar.py -v Expected: All tests FAIL because the current get_secret() does not check os.environ first.

  • Step 3: Implement the env-var lookup

Modify backend/src/cyclone/secrets.py. Add import os at the top and replace the get_secret function:

"""macOS Keychain secret accessor for Cyclone.

SP9. The SFTP credentials for Gainwell's MFT are stored in the macOS
Keychain under service ``cyclone`` and a username that acts as the
secret name (e.g. ``sftp.gainwell.password``). This module fetches
them by name.

Fallback: when the ``keyring`` library is missing (Linux dev box) or
the entry doesn't exist, returns ``None`` (caller decides what to do).
A stub secret ``<stub-secret>`` is provided for the SP9 stub flow.

Setup (one-time, by the operator):
    security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'

Verification:
    security find-generic-password -s cyclone -a sftp.gainwell.password -w

SP25: the lookup now resolves the secret in three tiers, in this order:

    1. Plain env var named exactly ``<name>`` — highest priority.
       Lets a Linux server or Docker container pass the MFT password
       via ``CYCLONE_SFTP_PASSWORD=...`` without touching the Keychain.
       Trailing/leading whitespace (including the ``\\n`` that .env
       files often leave at EOF) is stripped; an empty value is
       treated as absent.
    2. macOS Keychain via ``keyring`` — unchanged from SP9. Kept as a
       fallback so a macOS workstation that prefers
       ``security add-generic-password`` works without env-var exports.
    3. ``None`` — caller decides what to do (``SftpClient._connect``
       raises a precise ``RuntimeError`` on real-mode auth).
"""

from __future__ import annotations

import logging
import os
from typing import Optional

log = logging.getLogger(__name__)

SERVICE_NAME = "cyclone"
STUB_SECRET = "<stub-secret>"

# Try to import keyring lazily — it's an optional dep so the rest of
# the codebase doesn't fail on Linux dev boxes without it.
try:
    import keyring  # type: ignore[import-untyped]
    _HAS_KEYRING = True
except ImportError:
    keyring = None  # type: ignore[assignment]
    _HAS_KEYRING = False


def get_secret(name: str) -> Optional[str]:
    """Fetch a secret by name. Three-tier lookup: env var, Keychain, None.

    Args:
        name: The secret name. Matches both the env-var name and the
            Keychain account (e.g. ``"sftp.gainwell.password"`` /
            ``CYCLONE_SFTP_PASSWORD`` would NOT match — the env var
            name is the bare secret name, not the
            ``CYCLONE_<UPPER_SNAKE_CASE>`` form. Operators who want
            the env-var shortcut set the var named exactly ``<name>``).

            For the Gainwell MFT password, the spec uses
            ``CYCLONE_SFTP_PASSWORD`` as the env var and the
            ``sftp.gainwell.password`` Keychain account as the
            fallback. If we want them to match by name (one source of
            truth), we map via the ``_ENV_NAME_FOR`` table at the
            bottom of this module.

    Returns:
        The secret string (stripped), or ``None`` if no tier produced
        a value.
    """
    env_name = _ENV_NAME_FOR.get(name, name)
    raw = os.environ.get(env_name)
    if raw is not None:
        stripped = raw.strip()
        if stripped:
            log.debug("Secret %r resolved from env var %r", name, env_name)
            return stripped
        # Empty env var — treat as absent and fall through.

    if _HAS_KEYRING:
        try:
            value = keyring.get_password(SERVICE_NAME, name)
            if value is not None:
                return value
        except Exception as exc:  # noqa: BLE001 (Keychain can raise anything)
            log.warning("Keychain get_secret(%r) failed: %s", name, exc)
    return None


def set_secret(name: str, value: str) -> bool:
    """Set a secret in macOS Keychain. Returns True on success.

    Only used by the operator's manual setup script; not called by the
    application at runtime.
    """
    if not _HAS_KEYRING:
        log.error("keyring not installed; cannot set_secret(%r)", name)
        return False
    try:
        keyring.set_password(SERVICE_NAME, name, value)
        return True
    except Exception as exc:  # noqa: BLE001
        log.error("Keychain set_secret(%r) failed: %s", name, exc)
        return False


def has_keyring() -> bool:
    """True if the ``keyring`` library is importable (regardless of whether
    the Keychain entry actually exists)."""
    return _HAS_KEYRING


# Mapping from Keychain account name (used in ``SftpBlock.auth``) to
# the operator-facing env var. Keeping this table here means callers
# don't have to remember the difference between
# ``sftp.gainwell.password`` (Keychain account) and
# ``CYCLONE_SFTP_PASSWORD`` (env var).
_ENV_NAME_FOR: dict[str, str] = {
    "sftp.gainwell.password": "CYCLONE_SFTP_PASSWORD",
}
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_secrets_envvar.py -v Expected: All 8 tests PASS.

  • Step 5: Commit
git add backend/src/cyclone/secrets.py backend/tests/test_secrets_envvar.py
git commit -m "feat(sp25): secrets.get_secret() env-var-first lookup"

Task 2: Add store.update_clearhouse()

Files:

  • Modify: backend/src/cyclone/store.py

  • Test: backend/tests/test_store_update_clearhouse.py

  • Step 1: Write the failing tests

Create backend/tests/test_store_update_clearhouse.py:

"""SP25 — store.update_clearhouse() round-trip test.

The PATCH /api/clearhouse endpoint needs a way to replace the singleton
clearhouse row inside one session. This test exercises the method
directly against a fresh in-memory DB.
"""
from __future__ import annotations

import json

import pytest

from cyclone import db
from cyclone.providers import Clearhouse, SftpBlock, FilenameBlock
from cyclone.store import store as cycl_store


def _seed_clearhouse():
    """Insert the default clearhouse row used by SP9's lifespan seed."""
    cycl_store._seed_clearhouse_if_missing()


def test_update_clearhouse_round_trip():
    """Replace all fields on the singleton row; GET returns the new values."""
    _seed_clearhouse()

    new_block = SftpBlock(
        host="mft.gainwelltechnologies.com",
        port=22,
        username="colorado-fts\\coxix_prod_11525703",
        paths={
            "outbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
            "inbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE",
        },
        stub=False,
        staging_dir="./var/sftp/staging",
        auth={"password_keychain_account": "sftp.gainwell.password"},
    )

    new_clearhouse = Clearhouse.model_validate({
        "id": 1,
        "name": "dzinesco",
        "tpid": "11525703",
        "submitter_name": "Dzinesco",
        "submitter_id_qual": "46",
        "submitter_contact_name": "Tyler Martinez",
        "submitter_contact_email": "tyler@dzinesco.com",
        "filename_block": FilenameBlock(
            tz="America/Denver",
            outbound_template="{tpid}-{tx}-{ts_mt}-1of1.{ext}",
            inbound_template="TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
        ),
        "sftp_block": new_block,
    })

    cycl_store.update_clearhouse(new_clearhouse)

    fetched = cycl_store.get_clearhouse()
    assert fetched is not None
    assert fetched.sftp_block.stub is False
    assert fetched.sftp_block.host == "mft.gainwelltechnologies.com"
    assert fetched.sftp_block.auth == {"password_keychain_account": "sftp.gainwell.password"}


def test_update_clearhouse_missing_row_raises():
    """If the singleton row was never seeded, update raises LookupError."""
    # Wipe the DB.
    db._reset_for_tests()
    db.init_db()

    new_block = SftpBlock(
        host="mft.example.com", port=22, username="u",
        paths={"outbound": "/o", "inbound": "/i"},
        stub=True, staging_dir="/tmp", auth={},
    )
    new_clearhouse = Clearhouse.model_validate({
        "id": 1, "name": "x", "tpid": "1",
        "submitter_name": "X", "submitter_id_qual": "46",
        "submitter_contact_name": "X", "submitter_contact_email": "x@x",
        "filename_block": FilenameBlock(
            tz="America/Denver",
            outbound_template="{tpid}-{tx}-{ts}-1of1.{ext}",
            inbound_template="TP{tpid}-{tx}_{ts}.x12",
        ),
        "sftp_block": new_block,
    })

    with pytest.raises(LookupError):
        cycl_store.update_clearhouse(new_clearhouse)
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_store_update_clearhouse.py -v Expected: Both tests FAIL with AttributeError: module 'cyclone.store' has no attribute 'update_clearhouse' (or similar — the method does not exist yet).

  • Step 3: Implement update_clearhouse()

Modify backend/src/cyclone/store.py. Add the following method to the CycloneStore class, right after get_clearhouse (search for def get_clearhouse and insert after it):

    def update_clearhouse(self, block: Clearhouse) -> Clearhouse:
        """Replace the singleton clearhouse row. SP25.

        Used by ``PATCH /api/clearhouse`` to flip ``sftp_block.stub``
        and adjust host/port/paths without touching SQLite directly.
        Raises ``LookupError`` if the singleton row is missing — the
        caller is expected to run the lifespan seed first.
        """
        from cyclone.db import ClearhouseORM
        from cyclone.providers import Clearhouse as ClearhouseModel
        with db.SessionLocal()() as s:
            row = s.get(ClearhouseORM, 1)
            if row is None:
                raise LookupError(
                    "clearhouse singleton row missing; run the lifespan "
                    "seed (configure_scheduler) before PATCH /api/clearhouse"
                )
            row.name = block.name
            row.tpid = block.tpid
            row.submitter_name = block.submitter_name
            row.submitter_id_qual = block.submitter_id_qual
            row.submitter_contact_name = block.submitter_contact_name
            row.submitter_contact_email = block.submitter_contact_email
            row.filename_block_json = json.dumps(block.filename_block.model_dump())
            row.sftp_block_json = json.dumps(block.sftp_block.model_dump())
            row.updated_at = datetime.now(timezone.utc).isoformat()
            s.commit()
            return ClearhouseModel.model_validate({
                "id": 1,
                "name": row.name,
                "tpid": row.tpid,
                "submitter_name": row.submitter_name,
                "submitter_id_qual": row.submitter_id_qual,
                "submitter_contact_name": row.submitter_contact_name,
                "submitter_contact_email": row.submitter_contact_email,
                "filename_block": json.loads(row.filename_block_json),
                "sftp_block": json.loads(row.sftp_block_json),
            })

Make sure json, datetime, timezone are already imported in store.py — they are (the file already uses them).

  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_store_update_clearhouse.py -v Expected: Both tests PASS.

  • Step 5: Commit
git add backend/src/cyclone/store.py backend/tests/test_store_update_clearhouse.py
git commit -m "feat(sp25): store.update_clearhouse() for PATCH endpoint"

Task 3: Add scheduler.reconfigure_scheduler()

Files:

  • Modify: backend/src/cyclone/scheduler.py

  • Test: backend/tests/test_scheduler_reconfigure.py

  • Step 1: Write the failing tests

Create backend/tests/test_scheduler_reconfigure.py:

"""SP25 — scheduler.reconfigure_scheduler() hot-reload.

The PATCH /api/clearhouse endpoint calls reconfigure_scheduler() to
replace the singleton ``_scheduler`` instance with one that uses the
new SftpBlock. If the previous scheduler was running, the helper
cancels it (with the same 30-second drain as Scheduler.stop()) and
starts the new one. If it was stopped, the new one is left stopped.
"""
from __future__ import annotations

import asyncio
from pathlib import Path

import pytest

from cyclone import db, scheduler as sched_mod
from cyclone.providers import SftpBlock
from cyclone.scheduler import Scheduler


def _stub_block(stub: bool = True, host: str = "stub-host") -> SftpBlock:
    return SftpBlock(
        host=host,
        port=22,
        username="test-user",
        paths={"outbound": "/o", "inbound": "/i"},
        stub=stub,
        staging_dir="/tmp/cyclone-test",
        auth={},
    )


def _drain():
    """Yield once so any awaiting coroutine has a chance to advance."""
    return asyncio.sleep(0)


@pytest.fixture(autouse=True)
def _reset_scheduler():
    sched_mod.reset_scheduler_for_tests()
    yield
    sched_mod.reset_scheduler_for_tests()


def test_reconfigure_when_not_running():
    """If the previous scheduler is stopped, reconfigure replaces the
    singleton without starting it."""

    async def _go():
        block_a = _stub_block(stub=True, host="block-a")
        sched_a = sched_mod.configure_scheduler(block_a, sftp_block_name="a")
        assert not sched_a.is_running()

        block_b = _stub_block(stub=True, host="block-b")
        sched_b = await sched_mod.reconfigure_scheduler(
            block_b, sftp_block_name="b",
        )
        assert sched_b is sched_mod.get_scheduler()
        assert not sched_b.is_running()
        assert sched_b._sftp_block_name == "b"

    asyncio.run(_go())


def test_reconfigure_when_running_starts_new_one():
    """If the previous scheduler is running, reconfigure cancels it and
    starts the new one."""

    async def _go():
        block_a = _stub_block(stub=True, host="block-a")
        sched_a = sched_mod.configure_scheduler(block_a, sftp_block_name="a")
        await sched_a.start()
        assert sched_a.is_running()

        block_b = _stub_block(stub=True, host="block-b")
        sched_b = await sched_mod.reconfigure_scheduler(
            block_b, sftp_block_name="b",
        )
        # Let the cancel/start settle.
        await _drain()
        assert sched_b.is_running()
        assert sched_b._sftp_block_name == "b"

        await sched_b.stop()

    asyncio.run(_go())


def test_reconfigure_during_in_flight_tick():
    """If a tick is mid-flight, reconfigure waits for it (cooperative)
    then starts the new one. Verified by feeding the scheduler a slow
    client factory."""

    async def _go():
        block_a = _stub_block(stub=True, host="block-a")
        sched_mod.configure_scheduler(block_a, sftp_block_name="a")

        started = asyncio.Event()
        finish = asyncio.Event()

        async def _slow_tick():
            started.set()
            await finish.wait()

        # Inject a custom factory that returns a scheduler pre-armed
        # with a slow _tick_impl, so we can hold the "tick" mid-flight.
        class _FakeScheduler:
            def __init__(self, block, **_):
                self._block = block
                self._task = None
                self._running = False

            async def start(self):
                self._running = True
                self._task = asyncio.create_task(_slow_tick())

            async def stop(self):
                self._running = False
                if self._task:
                    self._task.cancel()
                    try:
                        await self._task
                    except asyncio.CancelledError:
                        pass
                self._task = None

            def is_running(self):
                return self._running

        # Monkey-patch the module-level Scheduler class for this test.
        original_scheduler_cls = sched_mod.Scheduler
        sched_mod.Scheduler = _FakeScheduler  # type: ignore[assignment]
        try:
            sched_mod.configure_scheduler(block_a, sftp_block_name="a")
            sched_a = sched_mod.get_scheduler()
            await sched_a.start()
            await started.wait()
            assert sched_a.is_running()

            # Now reconfigure. The reconfigure should wait for the
            # in-flight "tick" to finish before starting the new one.
            reconfigure_task = asyncio.create_task(
                sched_mod.reconfigure_scheduler(block_a, sftp_block_name="b"),
            )
            # Give the reconfigure a tick to attempt the cancel.
            await asyncio.sleep(0.05)
            assert not reconfigure_task.done(), "reconfigure should be waiting for in-flight tick"

            finish.set()
            sched_b = await reconfigure_task
            assert sched_b is sched_mod.get_scheduler()
        finally:
            sched_mod.Scheduler = original_scheduler_cls  # type: ignore[assignment]

    asyncio.run(_go())


def test_reconfigure_drain_timeout_matches_stop(monkeypatch):
    """The reconfigure's drain timeout matches Scheduler.stop() — both
    are 30 seconds. We can't wait 30s in a test, but we can verify the
    constant is shared."""

    # Both code paths should reference the same constant.
    assert hasattr(sched_mod, "_DRAIN_TIMEOUT_SECONDS")
    assert sched_mod._DRAIN_TIMEOUT_SECONDS == 30


def test_multiple_reconfigures_no_leaked_tasks():
    """Reconfiguring back and forth N times does not leak asyncio tasks."""

    async def _go():
        block = _stub_block(stub=True, host="block-a")
        sched_mod.configure_scheduler(block, sftp_block_name="a")
        sched = sched_mod.get_scheduler()
        await sched.start()

        before = len(asyncio.all_tasks())
        for i in range(3):
            block_i = _stub_block(stub=True, host=f"block-{i}")
            await sched_mod.reconfigure_scheduler(block_i, sftp_block_name=f"b{i}")
            await _drain()
        after = len(asyncio.all_tasks())

        # The new scheduler itself owns one task; allow +1.
        assert after - before <= 1, f"tasks leaked: before={before} after={after}"

        await sched_mod.get_scheduler().stop()

    asyncio.run(_go())
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_scheduler_reconfigure.py -v Expected: Tests FAIL — reconfigure_scheduler does not exist yet.

  • Step 3: Implement reconfigure_scheduler()

Modify backend/src/cyclone/scheduler.py. Add a constant near the top of the file (after STATUS_* definitions), and add the new function near the bottom (right after reset_scheduler_for_tests):

# How long reconfigure_scheduler waits for the in-flight tick to
# drain before cancelling the old task. Matches Scheduler.stop().
_DRAIN_TIMEOUT_SECONDS = 30

And add the function after reset_scheduler_for_tests:

async def reconfigure_scheduler(
    sftp_block: SftpBlock,
    *,
    poll_interval_seconds: int = 60,
    sftp_block_name: str = "default",
) -> Scheduler:
    """Replace the singleton scheduler; restart if it was running. SP25.

    Called from ``PATCH /api/clearhouse`` so the next scheduler tick
    uses the new SftpBlock without a process restart.

    Lifecycle:
        * If the previous scheduler is running, ``stop()`` it (waits
          up to ``_DRAIN_TIMEOUT_SECONDS`` for the current tick to
          finish — matches the existing ``Scheduler.stop()`` timeout).
        * Replace the module-level ``_scheduler`` singleton with a
          fresh ``Scheduler`` against the new block.
        * If the previous one was running, ``start()`` the new one.

    Threading: same constraint as ``configure_scheduler`` — must be
    called from the FastAPI event loop, not a worker thread.
    """
    global _scheduler
    was_running = False
    if _scheduler is not None:
        was_running = _scheduler.is_running()
        if was_running:
            # Drain the in-flight tick. Scheduler.stop() already
            # applies the _DRAIN_TIMEOUT_SECONDS timeout internally;
            # we just await it.
            await _scheduler.stop()
    poll = int(
        os.environ.get("CYCLONE_SCHEDULER_POLL_SECONDS", poll_interval_seconds),
    )
    _scheduler = Scheduler(
        sftp_block,
        poll_interval_seconds=poll,
        sftp_block_name=sftp_block_name,
    )
    if was_running:
        await _scheduler.start()
    log.info(
        "Scheduler reconfigured",
        extra={
            "was_running": was_running,
            "sftp_block": sftp_block_name,
            "host": sftp_block.host,
            "stub": sftp_block.stub,
        },
    )
    return _scheduler
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_scheduler_reconfigure.py -v Expected: All 5 tests PASS.

  • Step 5: Commit
git add backend/src/cyclone/scheduler.py backend/tests/test_scheduler_reconfigure.py
git commit -m "feat(sp25): scheduler.reconfigure_scheduler() hot-reload helper"

Task 4: Add PATCH /api/clearhouse endpoint

Files:

  • Modify: backend/src/cyclone/api.py

  • Test: backend/tests/test_api_clearhouse_patch.py

  • Step 1: Write the failing tests

Create backend/tests/test_api_clearhouse_patch.py:

"""SP25 — PATCH /api/clearhouse endpoint.

Lets the operator flip sftp_block.stub and adjust host/port/paths
without raw SQL. The endpoint validates via the Clearhouse Pydantic
model, writes via store.update_clearhouse(), then hot-reloads the
running scheduler via scheduler.reconfigure_scheduler().
"""
from __future__ import annotations

import json
from unittest.mock import AsyncMock, patch

import pytest

from cyclone import db, scheduler as sched_mod
from cyclone.providers import Clearhouse, SftpBlock, FilenameBlock
from cyclone.store import store as cycl_store


def _seed_and_login(client):
    """Insert the clearhouse row and bootstrap an admin session."""
    cycl_store._seed_clearhouse_if_missing()
    # Bootstrap auth — the autouse conftest in tests/conftest.py
    # already sets CYCLONE_DB_URL; we need an admin user + session.
    from cyclone.auth.bootstrap import ensure_admin_user
    from cyclone.auth.sessions import create_session
    from cyclone.security import hash_password
    ensure_admin_user("admin", "test-password-12345")
    r = client.post("/api/auth/login", json={
        "username": "admin", "password": "test-password-12345",
    })
    assert r.status_code == 200, r.text


def _clearhouse_body(stub: bool = True, host: str = "stub-host") -> dict:
    return {
        "id": 1,
        "name": "dzinesco",
        "tpid": "11525703",
        "submitter_name": "Dzinesco",
        "submitter_id_qual": "46",
        "submitter_contact_name": "Tyler Martinez",
        "submitter_contact_email": "tyler@dzinesco.com",
        "filename_block": {
            "tz": "America/Denver",
            "outbound_template": "{tpid}-{tx}-{ts}-1of1.{ext}",
            "inbound_template": "TP{tpid}-{tx}_{ts}.x12",
        },
        "sftp_block": {
            "host": host,
            "port": 22,
            "username": "colorado-fts\\coxix_prod_11525703",
            "paths": {
                "outbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
                "inbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE",
            },
            "stub": stub,
            "staging_dir": "./var/sftp/staging",
            "auth": {"password_keychain_account": "sftp.gainwell.password"},
        },
    }


def test_patch_flips_stub_and_get_reflects(client):
    _seed_and_login(client)
    body = _clearhouse_body(stub=True, host="stub-host")
    r = client.patch("/api/clearhouse", json=body)
    assert r.status_code == 200, r.text
    payload = r.json()
    assert payload["sftp_block"]["stub"] is False
    assert payload["sftp_block"]["host"] == "stub-host"

    r2 = client.get("/api/clearhouse")
    assert r2.status_code == 200
    assert r2.json()["sftp_block"]["stub"] is False


def test_patch_with_string_stub_returns_422(client):
    _seed_and_login(client)
    body = _clearhouse_body(stub=True)
    body["sftp_block"]["stub"] = "yes"  # string instead of bool
    r = client.patch("/api/clearhouse", json=body)
    assert r.status_code == 422


def test_patch_real_block_requires_host(client):
    _seed_and_login(client)
    body = _clearhouse_body(stub=False)
    body["sftp_block"]["host"] = ""
    r = client.patch("/api/clearhouse", json=body)
    assert r.status_code == 422


def test_patch_real_block_requires_password_keychain_account(client):
    _seed_and_login(client)
    body = _clearhouse_body(stub=False)
    body["sftp_block"]["auth"] = {"password_keychain_account": ""}
    r = client.patch("/api/clearhouse", json=body)
    assert r.status_code == 422


def test_patch_without_session_returns_401(client):
    cycl_store._seed_clearhouse_if_missing()
    body = _clearhouse_body(stub=False)
    r = client.patch("/api/clearhouse", json=body)
    assert r.status_code == 401


def test_patch_triggers_scheduler_reconfigure(client):
    """The PATCH must call scheduler.reconfigure_scheduler() with the new
    SftpBlock so the next tick uses real MFT instead of the stub."""
    _seed_and_login(client)

    with patch.object(
        sched_mod, "reconfigure_scheduler",
        AsyncMock(return_value=AsyncMock()),
    ) as mock_reconf:
        body = _clearhouse_body(stub=False, host="mft.gainwelltechnologies.com")
        r = client.patch("/api/clearhouse", json=body)
        assert r.status_code == 200, r.text
        assert mock_reconf.await_count == 1
        # The new sftp_block is passed positionally; sftp_block_name
        # comes from the clearhouse row's ``name`` field.
        call_args = mock_reconf.await_args
        assert call_args.args[0].stub is False
        assert call_args.args[0].host == "mft.gainwelltechnologies.com"
        assert call_args.kwargs.get("sftp_block_name") == "dzinesco"


def test_patch_returns_post_update_row(client):
    _seed_and_login(client)
    body = _clearhouse_body(stub=False, host="mft.example.com")
    r = client.patch("/api/clearhouse", json=body)
    assert r.status_code == 200
    payload = r.json()
    assert payload["name"] == "dzinesco"
    assert payload["tpid"] == "11525703"
    assert payload["sftp_block"]["stub"] is False
    assert payload["sftp_block"]["host"] == "mft.example.com"
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_api_clearhouse_patch.py -v Expected: All tests FAIL — PATCH /api/clearhouse does not exist (404 or 405).

  • Step 3: Implement PATCH /api/clearhouse

Modify backend/src/cyclone/api.py. Right after the existing GET /api/clearhouse endpoint (search for @app.get("/api/clearhouse") and insert after the function body:

@app.patch("/api/clearhouse", dependencies=[Depends(matrix_gate)])
async def patch_clearhouse(body: dict) -> Any:
    """Replace the singleton clearhouse row (SP25).

    The full ``Clearhouse`` model is required — we don't accept partial
    updates because the operator-facing use case is "I'm switching the
    loop to real MFT" or "I'm pointing at a different MFT server",
    not "I'm tweaking one field at a time." Validation errors are
    returned as 422 (Pydantic default).

    After a successful write, the running scheduler is hot-reloaded
    via ``scheduler.reconfigure_scheduler(force=True)`` so the next
    tick uses the new SftpBlock without a process restart.
    """
    from cyclone import scheduler as _scheduler_mod
    from cyclone.providers import Clearhouse as _Clearhouse

    # Validate via the same Pydantic model the spec uses, so the
    # request shape is the source of truth.
    try:
        parsed = _Clearhouse.model_validate(body)
    except Exception as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

    # SP25: when sftp_block.stub=false, the block must carry an auth
    # account name and a non-empty host. The Pydantic model catches
    # some of these; this catches the "empty password_keychain_account"
    # case (which Pydantic allows because it's a free-form dict).
    sb = parsed.sftp_block
    if not sb.stub:
        if not sb.host:
            raise HTTPException(
                status_code=422,
                detail="sftp_block.host is required when stub=false",
            )
        auth = sb.auth or {}
        if not auth.get("password_keychain_account") and not auth.get("key_file"):
            raise HTTPException(
                status_code=422,
                detail=(
                    "sftp_block.auth must contain either "
                    "'password_keychain_account' or 'key_file' when stub=false"
                ),
            )

    updated = store.update_clearhouse(parsed)
    await _scheduler_mod.reconfigure_scheduler(
        updated.sftp_block,
        sftp_block_name=updated.name or "default",
    )
    return json.loads(updated.model_dump_json())
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_api_clearhouse_patch.py -v Expected: All 7 tests PASS.

  • Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_clearhouse_patch.py
git commit -m "feat(sp25): PATCH /api/clearhouse with hot-reload"

Task 5: Write docs/RUNBOOK.md

Files:

  • Create: docs/RUNBOOK.md

  • Step 1: Draft the runbook

Create docs/RUNBOOK.md with the following content:

# Cyclone Operator Runbook

Procedures for running Cyclone in production. The end-user README
covers dev setup; this doc is for an operator bringing up SFTP polling
on a fresh host.

## SP25 — Enable SFTP Polling for Real Gainwell MFT

The inbound MFT polling scheduler ships in SP16 and is wired to real
paramiko SFTP in SP13. To turn it on for `mft.gainwelltechnologies.com`:

### Prerequisites

- Python 3.11+ with the `cyclone` package installed (`pip install -e .[dev,sftp]`).
- The macOS `keyring` library is optional. On a Linux server or Docker
  container, the MFT password is supplied via a plain env var (no
  Keychain dependency).
- Outbound TCP/22 to `mft.gainwelltechnologies.com`.

### Env vars

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `CYCLONE_SFTP_PASSWORD` | Yes (real MFT only) | unset | The MFT password. Stripped of whitespace; empty values are treated as unset. |
| `CYCLONE_SCHEDULER_AUTOSTART` | No | unset (falsy) | When `1`/`true`/`yes`, the scheduler starts polling on API launch. |
| `CYCLONE_SCHEDULER_POLL_SECONDS` | No | `60` | Seconds between poll cycles. The Gainwell MFT server doesn't push — we pull. |

### First-time setup

1. **Set the password env var.** On the server that runs Cyclone:

   ```bash
   export CYCLONE_SFTP_PASSWORD='the-actual-password'

For systemd / Docker, persist this in the service file or docker-compose.yml (use a _FILE companion or a secrets: block; see your platform's docs).

  1. Confirm the clearhouse SFTP block is configured. GET /api/clearhouse should return a row. If not, run the lifespan once to seed it (any API launch will do).

  2. Flip stub → false and point at real MFT. Authenticated as an admin:

    curl -X PATCH http://127.0.0.1:8000/api/clearhouse \
      -H 'Content-Type: application/json' \
      -b cookies.txt \
      -d '{
        "id": 1,
        "name": "dzinesco",
        "tpid": "11525703",
        "submitter_name": "Dzinesco",
        "submitter_id_qual": "46",
        "submitter_contact_name": "Tyler Martinez",
        "submitter_contact_email": "tyler@dzinesco.com",
        "filename_block": {
          "tz": "America/Denver",
          "outbound_template": "{tpid}-{tx}-{ts}-1of1.{ext}",
          "inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12"
        },
        "sftp_block": {
          "host": "mft.gainwelltechnologies.com",
          "port": 22,
          "username": "colorado-fts\\coxix_prod_11525703",
          "paths": {
            "outbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
            "inbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE"
          },
          "stub": false,
          "staging_dir": "./var/sftp/staging",
          "auth": {"password_keychain_account": "sftp.gainwell.password"}
        }
      }'
    

    The endpoint hot-reloads the scheduler. No API restart required.

  3. Start polling. Either:

    • Set CYCLONE_SCHEDULER_AUTOSTART=true and restart the API.

    • Or trigger manually:

      curl -X POST http://127.0.0.1:8000/api/admin/scheduler/start -b cookies.txt
      

Verification

# Is the loop running?
curl http://127.0.0.1:8000/api/admin/scheduler/status -b cookies.txt

# Force one poll cycle right now:
curl -X POST http://127.0.0.1:8000/api/admin/scheduler/tick -b cookies.txt

# What files has the scheduler seen?
curl 'http://127.0.0.1:8000/api/admin/scheduler/processed-files?limit=20' -b cookies.txt

# Only the errors?
curl 'http://127.0.0.1:8000/api/admin/scheduler/processed-files?status=error' -b cookies.txt

A successful first poll shows rows in processed-files with status=ok and a non-zero claim_count for 835/277CA files.

Troubleshooting

Symptom Likely cause Fix
status=error rows with AuthenticationException Wrong password in CYCLONE_SFTP_PASSWORD Re-export with the correct value, then PATCH /api/clearhouse or restart.
status=error rows with IOError: [Errno 111] Connection refused Outbound TCP/22 blocked, or wrong host/port Check the firewall; confirm host in GET /api/clearhouse.
Zero rows in processed-files after a tick Inbound MFT dir is empty (HPE hasn't pushed yet) — not an error Wait. Trigger tick again later.
RuntimeError: SFTP: Keychain entry ... missing or stub get_secret() fell through to Keychain (Linux + missing env var) Set CYCLONE_SFTP_PASSWORD.
RuntimeError: SftpBlock.auth must contain ... PATCH set stub=false without an auth block PATCH again with a complete auth dict.
Scheduler never starts (running: false) CYCLONE_SCHEDULER_AUTOSTART not set, no manual start call Either set autostart or POST /api/admin/scheduler/start.

macOS dev box variant

If you prefer the Keychain over env vars on macOS:

security add-generic-password -s cyclone -a sftp.gainwell.password -w '<password>'
security find-generic-password -s cyclone -a sftp.gainwell.password -w   # verify

The env var is the highest-priority lookup; the Keychain is the fallback. Setting both means the env var wins. To force the Keychain, unset the env var for that shell.


- [ ] **Step 2: Verify the file is readable**

Run: `ls -la docs/RUNBOOK.md && head -5 docs/RUNBOOK.md`
Expected: File exists, first line is `# Cyclone Operator Runbook`.

- [ ] **Step 3: Commit**

```bash
git add docs/RUNBOOK.md
git commit -m "docs(sp25): RUNBOOK.md — enable SFTP polling for real Gainwell MFT"

Task 6: Run full verification

Files: none (verification only)

  • Step 1: Run the full backend test suite

Run: cd backend && .venv/bin/pytest -q Expected: All previously-passing tests still pass; the 23 new tests (9 secrets + 2 store + 5 scheduler + 7 api) also pass. Total test count should be previous + 23.

  • Step 2: Run the backend linter

Run: cd backend && .venv/bin/ruff check src tests Expected: No lint errors.

  • Step 3: Run the backend typechecker

Run: cd backend && .venv/bin/mypy src/cyclone/secrets.py src/cyclone/scheduler.py src/cyclone/api.py src/cyclone/store.py Expected: No type errors. (mypy may not be configured for the project; if mypy.ini is missing, skip this step and note it in the PR description.)

  • Step 4: Smoke-test the endpoint manually (stub mode)

Run a stub-mode smoke test in a Python REPL or one-off script:

from fastapi.testclient import TestClient
from cyclone import db
from cyclone.store import store as cycl_store

db._reset_for_tests()
db.init_db()
cycl_store._seed_clearhouse_if_missing()

from cyclone.api import app
with TestClient(app) as client:
    # Bootstrap admin + login.
    from cyclone.auth.bootstrap import ensure_admin_user
    ensure_admin_user("admin", "test-password-12345")
    client.post("/api/auth/login", json={"username": "admin", "password": "test-password-12345"})
    # Verify GET.
    r = client.get("/api/clearhouse")
    assert r.status_code == 200
    assert r.json()["sftp_block"]["stub"] is True
    # PATCH.
    body = r.json()
    body["sftp_block"]["stub"] = False
    r2 = client.patch("/api/clearhouse", json=body)
    assert r2.status_code == 200, r2.text
    assert r2.json()["sftp_block"]["stub"] is False
    # Verify scheduler was reconfigured.
    r3 = client.get("/api/admin/scheduler/status")
    assert r3.status_code == 200

Expected: all assertions pass.

  • Step 5: Commit any verification artifacts

If the smoke-test script was saved as a file (e.g. scripts/sp25_smoke.py), commit it. Otherwise, no commit is needed for this task.


Self-Review Checklist (run before opening the PR)

  • All 8 secrets-envvar tests pass.
  • Both store-update-clearhouse tests pass.
  • All 5 scheduler-reconfigure tests pass.
  • All 7 api-clearhouse-patch tests pass.
  • RUNBOOK.md is committed.
  • No spec section is unimplemented. Verify against docs/superpowers/specs/2026-06-24-cyclone-sftp-polling-enablement-design.md:
    • §3.1 (env-var lookup) → Task 1
    • §3.2 (PATCH /api/clearhouse) → Tasks 2 + 4
    • §3.3 (reconfigure_scheduler) → Task 3
    • §3.4 (RUNBOOK.md) → Task 5
    • §6 (env vars table) → Task 5
    • §8 (testing plan) → Tasks 1, 2, 3, 4

Final PR

When all tasks pass, open a PR titled SP25 SFTP Polling Enablement against main. The merge is a single atomic merge commit per the SP-N flow — no squash, no rebase.