Files
resolutionflow/docs/plans/2026-02-17-maintenance-flows-plan.md
chihlasm d18ec88bc3 docs: add maintenance flows implementation plan
10-task plan across 4 phases: DB/API, frontend UI, APScheduler
integration, and target lists settings. TDD throughout.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 16:36:55 -05:00

77 KiB
Raw Blame History

Maintenance Flows Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add maintenance as a first-class flow type with batch multi-target session launching, saved target lists, and APScheduler-based automatic session creation on a cron schedule.

Architecture: New tree_type='maintenance' (DB migration), two new tables (target_lists, maintenance_schedules), batch_id + target_label on sessions, APScheduler in-process, and a new maintenance detail page in the frontend that owns scheduling + batch launch. Execution of individual sessions reuses ProceduralNavigationPage.

Tech Stack: FastAPI + SQLAlchemy async + Alembic (backend), APScheduler 3.x (apscheduler), React 19 + TypeScript + Zustand (frontend), Tailwind CSS v3, Lucide React icons.

Design doc: docs/plans/2026-02-17-maintenance-flows-design.md


Phase 1: Database + API

Task 1: Expand tree_type check constraint

Files:

  • Modify: backend/app/models/tree.py:22-35
  • Modify: backend/app/schemas/tree.py:10

Step 1: Update the model check constraint

In backend/app/models/tree.py, change:

CheckConstraint(
    "tree_type IN ('troubleshooting', 'procedural')",
    name='ck_trees_tree_type'
),

to:

CheckConstraint(
    "tree_type IN ('troubleshooting', 'procedural', 'maintenance')",
    name='ck_trees_tree_type'
),

Step 2: Update the TreeType literal in schemas

In backend/app/schemas/tree.py, change:

TreeType = Literal['troubleshooting', 'procedural']

to:

TreeType = Literal['troubleshooting', 'procedural', 'maintenance']

Step 3: Generate Alembic migration

cd backend && alembic revision --autogenerate -m "add maintenance tree type"

Open the generated file in backend/alembic/versions/. The autogenerate will likely produce an empty migration because check constraints aren't always detected. Replace the upgrade() and downgrade() functions with:

def upgrade() -> None:
    op.execute("ALTER TABLE trees DROP CONSTRAINT ck_trees_tree_type")
    op.execute(
        "ALTER TABLE trees ADD CONSTRAINT ck_trees_tree_type "
        "CHECK (tree_type IN ('troubleshooting', 'procedural', 'maintenance'))"
    )

def downgrade() -> None:
    # First set any maintenance trees back to procedural
    op.execute("UPDATE trees SET tree_type = 'procedural' WHERE tree_type = 'maintenance'")
    op.execute("ALTER TABLE trees DROP CONSTRAINT ck_trees_tree_type")
    op.execute(
        "ALTER TABLE trees ADD CONSTRAINT ck_trees_tree_type "
        "CHECK (tree_type IN ('troubleshooting', 'procedural'))"
    )

Step 4: Apply migration

cd backend && alembic upgrade head

Expected: Running upgrade ... -> <revision_id> with no errors.

Step 5: Verify manually

docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d+ trees" | grep tree_type

Expected: constraint shown as CHECK (tree_type = ANY (ARRAY['troubleshooting'::text, 'procedural'::text, 'maintenance'::text])).

Step 6: Write a failing test

Create backend/tests/test_maintenance_tree_type.py:

"""Tests for maintenance tree type."""
import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_create_maintenance_tree(client: AsyncClient, engineer_headers: dict):
    """Maintenance tree type is accepted by the API."""
    resp = await client.post(
        "/trees/",
        json={
            "name": "Update FSLogix",
            "description": "Monthly FSLogix update procedure",
            "tree_type": "maintenance",
            "tree_structure": {
                "steps": [
                    {"id": "step-1", "type": "procedure_step", "title": "Download installer",
                     "description": "Get latest FSLogix from Microsoft", "content_type": "action"},
                    {"id": "step-end", "type": "procedure_end", "title": "Complete"},
                ]
            },
        },
        headers=engineer_headers,
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()
    assert data["tree_type"] == "maintenance"


@pytest.mark.asyncio
async def test_list_maintenance_trees_filter(client: AsyncClient, engineer_headers: dict):
    """Filtering by tree_type=maintenance returns only maintenance trees."""
    # Create one maintenance tree
    await client.post(
        "/trees/",
        json={
            "name": "Maintenance Only",
            "tree_type": "maintenance",
            "tree_structure": {
                "steps": [
                    {"id": "s1", "type": "procedure_step", "title": "Step",
                     "description": "Do it", "content_type": "action"},
                    {"id": "end", "type": "procedure_end", "title": "Done"},
                ]
            },
        },
        headers=engineer_headers,
    )
    resp = await client.get("/trees/?tree_type=maintenance", headers=engineer_headers)
    assert resp.status_code == 200
    trees = resp.json()
    assert all(t["tree_type"] == "maintenance" for t in trees)
    assert len(trees) >= 1

Step 7: Run the test to verify it fails

cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts="

Expected: FAIL — the check constraint hasn't been applied yet to the test DB (or tree_type is rejected). If you haven't created the test DB migration, run alembic -x testing=true upgrade head or apply manually.

Step 8: Apply migration to test DB

DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head

Step 9: Run the tests again to verify they pass

cd backend && pytest tests/test_maintenance_tree_type.py -v --override-ini="addopts="

Expected: PASS.

Step 10: Commit

git add backend/app/models/tree.py backend/app/schemas/tree.py backend/alembic/versions/ backend/tests/test_maintenance_tree_type.py
git commit -m "feat: add maintenance tree_type with db migration and tests"

Task 2: Add target_lists table and API

Files:

  • Create: backend/app/models/target_list.py
  • Create: backend/app/schemas/target_list.py
  • Create: backend/app/api/endpoints/target_lists.py
  • Modify: backend/app/api/router.py
  • Create: backend/tests/test_target_lists.py

Step 1: Write the failing tests first

Create backend/tests/test_target_lists.py:

"""Tests for target lists CRUD."""
import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_create_target_list(client: AsyncClient, engineer_headers: dict):
    resp = await client.post(
        "/target-lists/",
        json={
            "name": "RDS Farm A",
            "description": "Production RDS servers",
            "targets": [
                {"label": "RDS-01", "notes": "192.168.1.10"},
                {"label": "RDS-02", "notes": "192.168.1.11"},
            ],
        },
        headers=engineer_headers,
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()
    assert data["name"] == "RDS Farm A"
    assert len(data["targets"]) == 2


@pytest.mark.asyncio
async def test_list_target_lists(client: AsyncClient, engineer_headers: dict):
    resp = await client.get("/target-lists/", headers=engineer_headers)
    assert resp.status_code == 200
    assert isinstance(resp.json(), list)


@pytest.mark.asyncio
async def test_update_target_list(client: AsyncClient, engineer_headers: dict):
    create = await client.post(
        "/target-lists/",
        json={"name": "Old Name", "targets": [{"label": "SRV-01"}]},
        headers=engineer_headers,
    )
    list_id = create.json()["id"]
    resp = await client.put(
        f"/target-lists/{list_id}",
        json={"name": "New Name", "targets": [{"label": "SRV-01"}, {"label": "SRV-02"}]},
        headers=engineer_headers,
    )
    assert resp.status_code == 200
    assert resp.json()["name"] == "New Name"
    assert len(resp.json()["targets"]) == 2


@pytest.mark.asyncio
async def test_delete_target_list(client: AsyncClient, engineer_headers: dict):
    create = await client.post(
        "/target-lists/",
        json={"name": "To Delete", "targets": [{"label": "X"}]},
        headers=engineer_headers,
    )
    list_id = create.json()["id"]
    resp = await client.delete(f"/target-lists/{list_id}", headers=engineer_headers)
    assert resp.status_code == 204

    # Verify gone
    get = await client.get(f"/target-lists/{list_id}", headers=engineer_headers)
    assert get.status_code == 404

Step 2: Run to verify tests fail

cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts="

Expected: FAIL — /target-lists/ route doesn't exist yet.

Step 3: Create the model

Create backend/app/models/target_list.py:

import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base

if TYPE_CHECKING:
    from app.models.user import User
    from app.models.team import Team


class TargetList(Base):
    __tablename__ = "target_lists"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
    )
    team_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"),
        nullable=False, index=True
    )
    created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    # targets: [{"label": "RDS-01", "notes": "optional notes"}, ...]
    targets: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc),
    )

Step 4: Create schemas

Create backend/app/schemas/target_list.py:

from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field


class TargetEntry(BaseModel):
    label: str = Field(..., min_length=1, max_length=255)
    notes: Optional[str] = Field(None, max_length=500)


class TargetListCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=255)
    description: Optional[str] = None
    targets: list[TargetEntry] = Field(..., min_length=1)


class TargetListUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1, max_length=255)
    description: Optional[str] = None
    targets: Optional[list[TargetEntry]] = None


class TargetListResponse(BaseModel):
    id: UUID
    team_id: UUID
    created_by: Optional[UUID]
    name: str
    description: Optional[str]
    targets: list[TargetEntry]
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}

Step 5: Generate + apply Alembic migration for target_lists

cd backend && alembic revision --autogenerate -m "add target_lists table"

Review the generated migration to ensure it creates target_lists with correct columns. Then:

alembic upgrade head
# Also apply to test DB:
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head

Step 6: Create the endpoint

Create backend/app/api/endpoints/target_lists.py:

"""Target lists CRUD endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_current_active_user, get_db
from app.models.target_list import TargetList
from app.models.user import User
from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse

router = APIRouter(prefix="/target-lists", tags=["target-lists"])


@router.get("/", response_model=list[TargetListResponse])
async def list_target_lists(
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """List all target lists for the current user's team."""
    if not current_user.team_id:
        return []
    result = await db.execute(
        select(TargetList)
        .where(TargetList.team_id == current_user.team_id)
        .order_by(TargetList.name)
    )
    return result.scalars().all()


@router.post("/", response_model=TargetListResponse, status_code=201)
async def create_target_list(
    data: TargetListCreate,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create a new target list for the current team."""
    if not current_user.team_id:
        raise HTTPException(status_code=400, detail="User must belong to a team")
    target_list = TargetList(
        team_id=current_user.team_id,
        created_by=current_user.id,
        name=data.name,
        description=data.description,
        targets=[t.model_dump() for t in data.targets],
    )
    db.add(target_list)
    await db.commit()
    await db.refresh(target_list)
    return target_list


@router.get("/{list_id}", response_model=TargetListResponse)
async def get_target_list(
    list_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(TargetList).where(
            TargetList.id == list_id,
            TargetList.team_id == current_user.team_id,
        )
    )
    target_list = result.scalar_one_or_none()
    if not target_list:
        raise HTTPException(status_code=404, detail="Target list not found")
    return target_list


@router.put("/{list_id}", response_model=TargetListResponse)
async def update_target_list(
    list_id: UUID,
    data: TargetListUpdate,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(TargetList).where(
            TargetList.id == list_id,
            TargetList.team_id == current_user.team_id,
        )
    )
    target_list = result.scalar_one_or_none()
    if not target_list:
        raise HTTPException(status_code=404, detail="Target list not found")
    if data.name is not None:
        target_list.name = data.name
    if data.description is not None:
        target_list.description = data.description
    if data.targets is not None:
        target_list.targets = [t.model_dump() for t in data.targets]
    await db.commit()
    await db.refresh(target_list)
    return target_list


@router.delete("/{list_id}", status_code=204)
async def delete_target_list(
    list_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(TargetList).where(
            TargetList.id == list_id,
            TargetList.team_id == current_user.team_id,
        )
    )
    target_list = result.scalar_one_or_none()
    if not target_list:
        raise HTTPException(status_code=404, detail="Target list not found")
    await db.delete(target_list)
    await db.commit()

Step 7: Register in router

In backend/app/api/router.py, add:

from app.api.endpoints import target_lists
# ...
api_router.include_router(target_lists.router)

Also add the import of TargetList to backend/app/models/__init__.py if that file exists (so Alembic detects it), or just ensure the model is imported somewhere in the startup path. Check backend/app/core/database.py — if it imports models explicitly, add TargetList there.

Step 8: Run the tests to verify they pass

cd backend && pytest tests/test_target_lists.py -v --override-ini="addopts="

Expected: PASS (4 tests).

Step 9: Commit

git add backend/app/models/target_list.py backend/app/schemas/target_list.py \
    backend/app/api/endpoints/target_lists.py backend/app/api/router.py \
    backend/alembic/versions/ backend/tests/test_target_lists.py
git commit -m "feat: add target_lists table, schema, and CRUD endpoints"

Task 3: Add batch_id + target_label to sessions + batch launch endpoint

Files:

  • Modify: backend/app/models/session.py
  • Modify: backend/app/schemas/ (session schemas — find SessionCreate or SessionResponse)
  • Modify: backend/app/api/endpoints/sessions.py
  • Create: backend/tests/test_batch_sessions.py

Step 1: Write failing tests

Create backend/tests/test_batch_sessions.py:

"""Tests for batch session launching."""
import pytest
from httpx import AsyncClient


async def _create_maintenance_tree(client, headers):
    resp = await client.post(
        "/trees/",
        json={
            "name": "Patch RDS",
            "tree_type": "maintenance",
            "tree_structure": {
                "steps": [
                    {"id": "s1", "type": "procedure_step", "title": "Install patch",
                     "description": "Run installer", "content_type": "action"},
                    {"id": "end", "type": "procedure_end", "title": "Done"},
                ]
            },
        },
        headers=headers,
    )
    assert resp.status_code == 201
    return resp.json()["id"]


@pytest.mark.asyncio
async def test_batch_launch_creates_one_session_per_target(
    client: AsyncClient, engineer_headers: dict
):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    resp = await client.post(
        "/sessions/batch",
        json={
            "tree_id": tree_id,
            "targets": [
                {"label": "RDS-01", "notes": "192.168.1.10"},
                {"label": "RDS-02", "notes": "192.168.1.11"},
                {"label": "RDS-03"},
            ],
        },
        headers=engineer_headers,
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()
    assert data["count"] == 3
    assert data["batch_id"] is not None
    sessions = data["sessions"]
    assert len(sessions) == 3
    # All share the same batch_id
    batch_ids = {s["batch_id"] for s in sessions}
    assert len(batch_ids) == 1
    # Each has a distinct target_label
    labels = {s["target_label"] for s in sessions}
    assert labels == {"RDS-01", "RDS-02", "RDS-03"}


@pytest.mark.asyncio
async def test_batch_launch_rejects_empty_targets(
    client: AsyncClient, engineer_headers: dict
):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    resp = await client.post(
        "/sessions/batch",
        json={"tree_id": tree_id, "targets": []},
        headers=engineer_headers,
    )
    assert resp.status_code == 422

Step 2: Run to verify tests fail

cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts="

Expected: FAIL — /sessions/batch doesn't exist.

Step 3: Add columns to the Session model

In backend/app/models/session.py, after the existing columns, add:

# Batch tracking (maintenance flows)
batch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
    UUID(as_uuid=True), nullable=True, index=True
)
target_label: Mapped[Optional[str]] = mapped_column(
    String(255), nullable=True
)

Step 4: Generate + apply migration

cd backend && alembic revision --autogenerate -m "add batch_id and target_label to sessions"
alembic upgrade head
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head

Step 5: Find and update the SessionResponse schema

Search for where SessionResponse or the session response schema is defined:

grep -r "class Session" backend/app/schemas/ -l

Open that file and add batch_id and target_label fields to both the create/response schemas:

# In the response schema:
batch_id: Optional[UUID] = None
target_label: Optional[str] = None

Step 6: Add the batch launch endpoint

In backend/app/api/endpoints/sessions.py, find where other session routes are defined and add at the end:

from pydantic import BaseModel, Field
import uuid as uuid_mod

class BatchTarget(BaseModel):
    label: str = Field(..., min_length=1, max_length=255)
    notes: Optional[str] = None

class BatchLaunchRequest(BaseModel):
    tree_id: UUID
    targets: list[BatchTarget] = Field(..., min_length=1)

class BatchLaunchResponse(BaseModel):
    batch_id: UUID
    count: int
    sessions: list  # list of session response dicts


@router.post("/batch", status_code=201)
async def batch_launch_sessions(
    data: BatchLaunchRequest,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    """Create one session per target for a maintenance flow batch run."""
    # Verify tree exists and belongs to user's team / is accessible
    tree_result = await db.execute(
        select(Tree).where(Tree.id == data.tree_id)
    )
    tree = tree_result.scalar_one_or_none()
    if not tree:
        raise HTTPException(status_code=404, detail="Tree not found")
    if tree.tree_type != "maintenance":
        raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows")

    batch_id = uuid_mod.uuid4()
    created_sessions = []

    for target in data.targets:
        session = Session(
            tree_id=tree.id,
            user_id=current_user.id,
            tree_snapshot=tree.tree_structure,
            path_taken=[],
            decisions=[],
            custom_steps=[],
            session_variables={},
            batch_id=batch_id,
            target_label=target.label,
        )
        db.add(session)
        created_sessions.append(session)

    await db.flush()
    for s in created_sessions:
        await db.refresh(s)
    await db.commit()

    return {
        "batch_id": str(batch_id),
        "count": len(created_sessions),
        "sessions": [
            {
                "id": str(s.id),
                "batch_id": str(s.batch_id),
                "target_label": s.target_label,
                "tree_id": str(s.tree_id),
            }
            for s in created_sessions
        ],
    }

Make sure Tree is imported in sessions.py — add from app.models.tree import Tree if not already present.

Step 7: Run the tests

cd backend && pytest tests/test_batch_sessions.py -v --override-ini="addopts="

Expected: PASS (2 tests).

Step 8: Commit

git add backend/app/models/session.py backend/app/schemas/ backend/app/api/endpoints/sessions.py \
    backend/alembic/versions/ backend/tests/test_batch_sessions.py
git commit -m "feat: add batch_id/target_label to sessions and batch launch endpoint"

Task 4: maintenance_schedules table + API

Files:

  • Create: backend/app/models/maintenance_schedule.py
  • Create: backend/app/schemas/maintenance_schedule.py
  • Create: backend/app/api/endpoints/maintenance_schedules.py
  • Modify: backend/app/api/router.py
  • Create: backend/tests/test_maintenance_schedules.py

Step 1: Write failing tests

Create backend/tests/test_maintenance_schedules.py:

"""Tests for maintenance schedule CRUD."""
import pytest
from httpx import AsyncClient


async def _create_maintenance_tree(client, headers):
    resp = await client.post(
        "/trees/",
        json={
            "name": "Scheduled Maintenance",
            "tree_type": "maintenance",
            "tree_structure": {
                "steps": [
                    {"id": "s1", "type": "procedure_step", "title": "Step 1",
                     "description": "Do it", "content_type": "action"},
                    {"id": "end", "type": "procedure_end", "title": "Done"},
                ]
            },
        },
        headers=headers,
    )
    return resp.json()["id"]


@pytest.mark.asyncio
async def test_create_schedule(client: AsyncClient, engineer_headers: dict):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    resp = await client.post(
        f"/maintenance-schedules/",
        json={
            "tree_id": tree_id,
            "cron_expression": "0 9 15 * *",
            "timezone": "America/New_York",
        },
        headers=engineer_headers,
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()
    assert data["cron_expression"] == "0 9 15 * *"
    assert data["is_active"] is True
    assert data["next_run_at"] is not None


@pytest.mark.asyncio
async def test_get_schedule_for_tree(client: AsyncClient, engineer_headers: dict):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    await client.post(
        "/maintenance-schedules/",
        json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"},
        headers=engineer_headers,
    )
    resp = await client.get(f"/maintenance-schedules/tree/{tree_id}", headers=engineer_headers)
    assert resp.status_code == 200
    assert resp.json()["cron_expression"] == "0 0 1 * *"


@pytest.mark.asyncio
async def test_disable_schedule(client: AsyncClient, engineer_headers: dict):
    tree_id = await _create_maintenance_tree(client, engineer_headers)
    create = await client.post(
        "/maintenance-schedules/",
        json={"tree_id": tree_id, "cron_expression": "0 6 * * 1", "timezone": "UTC"},
        headers=engineer_headers,
    )
    sched_id = create.json()["id"]
    resp = await client.patch(
        f"/maintenance-schedules/{sched_id}",
        json={"is_active": False},
        headers=engineer_headers,
    )
    assert resp.status_code == 200
    assert resp.json()["is_active"] is False

Step 2: Run to verify they fail

cd backend && pytest tests/test_maintenance_schedules.py -v --override-ini="addopts="

Expected: FAIL.

Step 3: Create the model

Create backend/app/models/maintenance_schedule.py:

import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base


class MaintenanceSchedule(Base):
    __tablename__ = "maintenance_schedules"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
    )
    tree_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"),
        nullable=False, index=True, unique=True  # one schedule per tree
    )
    created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
    )
    cron_expression: Mapped[str] = mapped_column(String(100), nullable=False)
    timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC")
    target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True), ForeignKey("target_lists.id", ondelete="SET NULL"), nullable=True
    )
    is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
    next_run_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    last_run_at: Mapped[Optional[datetime]] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc),
    )

Step 4: Create schemas

Create backend/app/schemas/maintenance_schedule.py:

from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field


class MaintenanceScheduleCreate(BaseModel):
    tree_id: UUID
    cron_expression: str = Field(..., min_length=9, max_length=100)
    timezone: str = Field("UTC", max_length=100)
    target_list_id: Optional[UUID] = None


class MaintenanceScheduleUpdate(BaseModel):
    cron_expression: Optional[str] = Field(None, min_length=9, max_length=100)
    timezone: Optional[str] = Field(None, max_length=100)
    target_list_id: Optional[UUID] = None
    is_active: Optional[bool] = None


class MaintenanceScheduleResponse(BaseModel):
    id: UUID
    tree_id: UUID
    created_by: Optional[UUID]
    cron_expression: str
    timezone: str
    target_list_id: Optional[UUID]
    is_active: bool
    next_run_at: Optional[datetime]
    last_run_at: Optional[datetime]
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}

Step 5: Create endpoint

Create backend/app/api/endpoints/maintenance_schedules.py:

"""Maintenance schedule CRUD endpoints."""
from typing import Annotated, Optional
from uuid import UUID
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from croniter import croniter

from app.api.deps import get_current_active_user, get_db
from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.user import User
from app.schemas.maintenance_schedule import (
    MaintenanceScheduleCreate,
    MaintenanceScheduleUpdate,
    MaintenanceScheduleResponse,
)

router = APIRouter(prefix="/maintenance-schedules", tags=["maintenance-schedules"])


def _compute_next_run(cron_expression: str, tz_name: str) -> datetime:
    """Compute next run time from cron expression."""
    import pytz
    tz = pytz.timezone(tz_name)
    now = datetime.now(tz)
    cron = croniter(cron_expression, now)
    return cron.get_next(datetime).astimezone(timezone.utc)


@router.post("/", response_model=MaintenanceScheduleResponse, status_code=201)
async def create_schedule(
    data: MaintenanceScheduleCreate,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    # Check no existing schedule for this tree
    existing = await db.execute(
        select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == data.tree_id)
    )
    if existing.scalar_one_or_none():
        raise HTTPException(status_code=409, detail="Schedule already exists for this tree. Update the existing one.")

    next_run = _compute_next_run(data.cron_expression, data.timezone)
    schedule = MaintenanceSchedule(
        tree_id=data.tree_id,
        created_by=current_user.id,
        cron_expression=data.cron_expression,
        timezone=data.timezone,
        target_list_id=data.target_list_id,
        is_active=True,
        next_run_at=next_run,
    )
    db.add(schedule)
    await db.commit()
    await db.refresh(schedule)
    return schedule


@router.get("/tree/{tree_id}", response_model=MaintenanceScheduleResponse)
async def get_schedule_for_tree(
    tree_id: UUID,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id)
    )
    schedule = result.scalar_one_or_none()
    if not schedule:
        raise HTTPException(status_code=404, detail="No schedule found for this tree")
    return schedule


@router.patch("/{schedule_id}", response_model=MaintenanceScheduleResponse)
async def update_schedule(
    schedule_id: UUID,
    data: MaintenanceScheduleUpdate,
    current_user: Annotated[User, Depends(get_current_active_user)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    result = await db.execute(
        select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule_id)
    )
    schedule = result.scalar_one_or_none()
    if not schedule:
        raise HTTPException(status_code=404, detail="Schedule not found")

    if data.cron_expression is not None:
        schedule.cron_expression = data.cron_expression
    if data.timezone is not None:
        schedule.timezone = data.timezone
    if data.target_list_id is not None:
        schedule.target_list_id = data.target_list_id
    if data.is_active is not None:
        schedule.is_active = data.is_active

    # Recompute next_run_at if cron or timezone changed
    if data.cron_expression or data.timezone:
        schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone)

    await db.commit()
    await db.refresh(schedule)
    return schedule

Step 6: Install dependencies

cd backend && pip install croniter pytz apscheduler

Add to backend/requirements.txt:

croniter>=2.0.0
pytz>=2024.1
apscheduler>=3.10.0

Step 7: Register in router

In backend/app/api/router.py, add:

from app.api.endpoints import maintenance_schedules
# ...
api_router.include_router(maintenance_schedules.router)

Step 8: Generate + apply migration

cd backend && alembic revision --autogenerate -m "add maintenance_schedules table"
alembic upgrade head
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly_test alembic upgrade head

Step 9: Run tests

cd backend && pytest tests/test_maintenance_schedules.py -v --override-ini="addopts="

Expected: PASS (3 tests).

Step 10: Run all backend tests to check nothing broke

cd backend && pytest --override-ini="addopts="

Expected: All pass (or same failures as before this feature).

Step 11: Commit

git add backend/app/models/maintenance_schedule.py backend/app/schemas/maintenance_schedule.py \
    backend/app/api/endpoints/maintenance_schedules.py backend/app/api/router.py \
    backend/alembic/versions/ backend/requirements.txt backend/tests/test_maintenance_schedules.py
git commit -m "feat: add maintenance_schedules table, schema, and CRUD endpoints"

Phase 2: Core Frontend UI

Task 5: Types, API clients, and routing

Files:

  • Create: frontend/src/types/maintenance.ts
  • Modify: frontend/src/types/index.ts
  • Create: frontend/src/api/targetLists.ts
  • Create: frontend/src/api/maintenanceSchedules.ts
  • Modify: frontend/src/api/index.ts
  • Modify: frontend/src/lib/routing.ts

Step 1: Create TypeScript types

Create frontend/src/types/maintenance.ts:

export interface TargetEntry {
  label: string
  notes?: string
}

export interface TargetList {
  id: string
  team_id: string
  created_by?: string
  name: string
  description?: string
  targets: TargetEntry[]
  created_at: string
  updated_at: string
}

export interface TargetListCreate {
  name: string
  description?: string
  targets: TargetEntry[]
}

export interface MaintenanceSchedule {
  id: string
  tree_id: string
  created_by?: string
  cron_expression: string
  timezone: string
  target_list_id?: string
  is_active: boolean
  next_run_at?: string
  last_run_at?: string
  created_at: string
  updated_at: string
}

export interface MaintenanceScheduleCreate {
  tree_id: string
  cron_expression: string
  timezone: string
  target_list_id?: string
}

export interface MaintenanceScheduleUpdate {
  cron_expression?: string
  timezone?: string
  target_list_id?: string
  is_active?: boolean
}

export interface BatchLaunchRequest {
  tree_id: string
  targets: TargetEntry[]
}

export interface BatchLaunchResponse {
  batch_id: string
  count: number
  sessions: Array<{
    id: string
    batch_id: string
    target_label: string
    tree_id: string
  }>
}

Step 2: Export from index

In frontend/src/types/index.ts, add at the end:

export type {
  TargetEntry,
  TargetList,
  TargetListCreate,
  MaintenanceSchedule,
  MaintenanceScheduleCreate,
  MaintenanceScheduleUpdate,
  BatchLaunchRequest,
  BatchLaunchResponse,
} from './maintenance'

Step 3: Create API clients

Create frontend/src/api/targetLists.ts:

import { apiClient } from './client'
import type { TargetList, TargetListCreate } from '@/types'

export const targetListsApi = {
  list: (): Promise<TargetList[]> =>
    apiClient.get('/target-lists/').then(r => r.data),

  get: (id: string): Promise<TargetList> =>
    apiClient.get(`/target-lists/${id}`).then(r => r.data),

  create: (data: TargetListCreate): Promise<TargetList> =>
    apiClient.post('/target-lists/', data).then(r => r.data),

  update: (id: string, data: Partial<TargetListCreate>): Promise<TargetList> =>
    apiClient.put(`/target-lists/${id}`, data).then(r => r.data),

  delete: (id: string): Promise<void> =>
    apiClient.delete(`/target-lists/${id}`).then(() => undefined),
}

Create frontend/src/api/maintenanceSchedules.ts:

import { apiClient } from './client'
import type {
  MaintenanceSchedule,
  MaintenanceScheduleCreate,
  MaintenanceScheduleUpdate,
  BatchLaunchRequest,
  BatchLaunchResponse,
} from '@/types'

export const maintenanceSchedulesApi = {
  getForTree: (treeId: string): Promise<MaintenanceSchedule> =>
    apiClient.get(`/maintenance-schedules/tree/${treeId}`).then(r => r.data),

  create: (data: MaintenanceScheduleCreate): Promise<MaintenanceSchedule> =>
    apiClient.post('/maintenance-schedules/', data).then(r => r.data),

  update: (id: string, data: MaintenanceScheduleUpdate): Promise<MaintenanceSchedule> =>
    apiClient.patch(`/maintenance-schedules/${id}`, data).then(r => r.data),
}

export const batchLaunchApi = {
  launch: (data: BatchLaunchRequest): Promise<BatchLaunchResponse> =>
    apiClient.post('/sessions/batch', data).then(r => r.data),
}

Step 4: Export from api/index.ts

In frontend/src/api/index.ts, add:

export { targetListsApi } from './targetLists'
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'

Step 5: Update routing helper

In frontend/src/lib/routing.ts, update getTreeNavigatePath:

export function getTreeNavigatePath(
  treeId: string,
  treeType?: string
): string {
  if (treeType === 'procedural') {
    return `/flows/${treeId}/navigate`
  }
  if (treeType === 'maintenance') {
    return `/flows/${treeId}/maintenance`
  }
  return `/trees/${treeId}/navigate`
}

export function getTreeEditorPath(
  treeId: string,
  treeType?: string
): string {
  if (treeType === 'procedural' || treeType === 'maintenance') {
    return `/flows/${treeId}/edit`
  }
  return `/trees/${treeId}/edit`
}

Step 6: Build check

cd frontend && npm run build

Expected: No TypeScript errors.

Step 7: Commit

git add frontend/src/types/maintenance.ts frontend/src/types/index.ts \
    frontend/src/api/targetLists.ts frontend/src/api/maintenanceSchedules.ts \
    frontend/src/api/index.ts frontend/src/lib/routing.ts
git commit -m "feat: add maintenance types, API clients, and routing for maintenance flows"

Task 6: Sidebar + TreeLibraryPage updates

Files:

  • Modify: frontend/src/components/layout/Sidebar.tsx
  • Modify: frontend/src/pages/TreeLibraryPage.tsx

Step 1: Update Sidebar to count and show maintenance flows

In frontend/src/components/layout/Sidebar.tsx:

Change treeCounts state (line ~32):

const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })

In the fetchData function, update the counts:

const maintenance = allTrees.filter(t => t.tree_type === 'maintenance').length
setTreeCounts({ total, troubleshooting, procedural, maintenance })

Update the nav sub-items (line ~145):

children={[
  { href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
  { href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
  { href: '/trees?type=maintenance', label: 'Maintenance', count: treeCounts.maintenance || undefined },
]}

Step 2: Update TreeLibraryPage type filter

In frontend/src/pages/TreeLibraryPage.tsx:

Change the typeFilter state type and initialization (line ~40):

const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural' | 'maintenance'>(
  urlType === 'troubleshooting' || urlType === 'procedural' || urlType === 'maintenance' ? urlType : 'all'
)

Update the sync effect (line ~47):

if (t === 'troubleshooting' || t === 'procedural' || t === 'maintenance') {
  setTypeFilter(t)
} else {
  setTypeFilter('all')
}

Step 3: Add maintenance badge to tree cards

Find the tree card component(s) in frontend/src/components/library/ (likely TreeGridView.tsx, TreeListView.tsx, TreeTableView.tsx). In each, find where the tree_type badge is rendered and add the maintenance case:

Look for something like tree_type === 'procedural' and add:

{tree.tree_type === 'maintenance' && (
  <span className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide text-amber-400">
    <Wrench className="h-3 w-3" />
    Maintenance
  </span>
)}

Import Wrench from lucide-react at the top of those files.

Step 4: Build check

cd frontend && npm run build

Expected: No errors.

Step 5: Commit

git add frontend/src/components/layout/Sidebar.tsx frontend/src/pages/TreeLibraryPage.tsx \
    frontend/src/components/library/
git commit -m "feat: add maintenance flows to sidebar nav and library type filter"

Task 7: BatchLaunchModal component

Files:

  • Create: frontend/src/components/maintenance/BatchLaunchModal.tsx

Step 1: Create the component

Create frontend/src/components/maintenance/BatchLaunchModal.tsx:

import { useState } from 'react'
import { X, List, Clock, PenLine, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { targetListsApi, batchLaunchApi } from '@/api'
import type { TargetList, TargetEntry } from '@/types'

interface BatchLaunchModalProps {
  treeId: string
  treeName: string
  onClose: () => void
  onLaunched: (batchId: string, count: number) => void
}

type TabId = 'saved' | 'previous' | 'manual' | 'psa'

export function BatchLaunchModal({ treeId, treeName, onClose, onLaunched }: BatchLaunchModalProps) {
  const [activeTab, setActiveTab] = useState<TabId>('manual')
  const [savedLists, setSavedLists] = useState<TargetList[] | null>(null)
  const [selectedListId, setSelectedListId] = useState<string | null>(null)
  const [manualInput, setManualInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [isLaunching, setIsLaunching] = useState(false)

  const loadSavedLists = async () => {
    if (savedLists !== null) return
    try {
      const lists = await targetListsApi.list()
      setSavedLists(lists)
    } catch {
      toast.error('Failed to load saved lists')
    }
  }

  const handleTabChange = (tab: TabId) => {
    setActiveTab(tab)
    if (tab === 'saved') loadSavedLists()
  }

  const getTargets = (): TargetEntry[] => {
    if (activeTab === 'saved' && selectedListId && savedLists) {
      const list = savedLists.find(l => l.id === selectedListId)
      return list?.targets ?? []
    }
    if (activeTab === 'manual') {
      return manualInput
        .split('\n')
        .map(l => l.trim())
        .filter(Boolean)
        .map(label => ({ label }))
    }
    return []
  }

  const targets = getTargets()

  const handleLaunch = async () => {
    if (targets.length === 0) {
      toast.error('Add at least one target before launching')
      return
    }
    setIsLaunching(true)
    try {
      const result = await batchLaunchApi.launch({ tree_id: treeId, targets })
      toast.success(`Launched ${result.count} sessions`)
      onLaunched(result.batch_id, result.count)
    } catch {
      toast.error('Failed to launch batch')
    } finally {
      setIsLaunching(false)
    }
  }

  const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
    { id: 'manual', label: 'Manual Entry', icon: <PenLine className="h-3.5 w-3.5" /> },
    { id: 'saved', label: 'Saved List', icon: <List className="h-3.5 w-3.5" /> },
    { id: 'previous', label: 'Previous Run', icon: <Clock className="h-3.5 w-3.5" /> },
    { id: 'psa', label: 'PSA/RMM Import', icon: <ExternalLink className="h-3.5 w-3.5" /> },
  ]

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
      <div className="w-full max-w-lg rounded-xl border border-border bg-card shadow-2xl">
        {/* Header */}
        <div className="flex items-center justify-between border-b border-border px-6 py-4">
          <div>
            <h2 className="text-base font-semibold text-foreground">Batch Launch</h2>
            <p className="text-[0.8125rem] text-muted-foreground">{treeName}</p>
          </div>
          <button onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
            <X className="h-4 w-4" />
          </button>
        </div>

        {/* Tabs */}
        <div className="flex gap-1 border-b border-border px-4 pt-3">
          {tabs.map(tab => (
            <button
              key={tab.id}
              onClick={() => handleTabChange(tab.id)}
              className={cn(
                "flex items-center gap-1.5 rounded-t-md px-3 py-2 font-label text-[0.75rem] uppercase tracking-wide transition-colors",
                activeTab === tab.id
                  ? "border-b-2 border-primary text-foreground"
                  : "text-muted-foreground hover:text-foreground"
              )}
            >
              {tab.icon}
              {tab.label}
            </button>
          ))}
        </div>

        {/* Tab Content */}
        <div className="p-6">
          {activeTab === 'manual' && (
            <div className="space-y-2">
              <label className="font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
                Server names (one per line)
              </label>
              <textarea
                className="h-40 w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
                placeholder={"RDS-01\nRDS-02\nRDS-03"}
                value={manualInput}
                onChange={e => setManualInput(e.target.value)}
              />
            </div>
          )}

          {activeTab === 'saved' && (
            <div className="space-y-2">
              {savedLists === null ? (
                <p className="text-sm text-muted-foreground">Loading...</p>
              ) : savedLists.length === 0 ? (
                <p className="text-sm text-muted-foreground">No saved lists yet. Create one in Team Settings  Target Lists.</p>
              ) : (
                <div className="space-y-2">
                  {savedLists.map(list => (
                    <button
                      key={list.id}
                      onClick={() => setSelectedListId(list.id)}
                      className={cn(
                        "w-full rounded-lg border px-4 py-3 text-left transition-colors",
                        selectedListId === list.id
                          ? "border-primary/30 bg-primary/10 text-foreground"
                          : "border-border text-muted-foreground hover:border-border/80 hover:bg-accent hover:text-foreground"
                      )}
                    >
                      <div className="font-medium">{list.name}</div>
                      <div className="text-[0.8125rem]">{list.targets.length} targets</div>
                    </button>
                  ))}
                </div>
              )}
            </div>
          )}

          {activeTab === 'previous' && (
            <p className="text-sm text-muted-foreground">Previous run history will appear here.</p>
          )}

          {activeTab === 'psa' && (
            <div className="rounded-lg border border-border bg-accent/30 p-4 text-center">
              <ExternalLink className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
              <p className="font-medium text-foreground">PSA/RMM Import</p>
              <p className="mt-1 text-[0.8125rem] text-muted-foreground">ConnectWise, Kaseya, and RMM integrations coming soon.</p>
            </div>
          )}
        </div>

        {/* Preview + Footer */}
        {targets.length > 0 && (
          <div className="border-t border-border px-6 py-3">
            <p className="text-[0.8125rem] text-muted-foreground">
              Will create <span className="font-semibold text-foreground">{targets.length} sessions</span>:{' '}
              {targets.slice(0, 5).map(t => t.label).join(', ')}
              {targets.length > 5 && ` +${targets.length - 5} more`}
            </p>
          </div>
        )}

        <div className="flex justify-end gap-2 border-t border-border px-6 py-4">
          <button
            onClick={onClose}
            className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
          >
            Cancel
          </button>
          <button
            onClick={handleLaunch}
            disabled={isLaunching || targets.length === 0}
            className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
          >
            {isLaunching ? 'Launching...' : `Launch ${targets.length > 0 ? targets.length + ' Sessions' : ''}`}
          </button>
        </div>
      </div>
    </div>
  )
}

Step 2: Build check

cd frontend && npm run build

Expected: No errors.

Step 3: Commit

git add frontend/src/components/maintenance/
git commit -m "feat: add BatchLaunchModal for maintenance flow multi-target launching"

Task 8: MaintenanceFlowDetailPage

Files:

  • Create: frontend/src/pages/MaintenanceFlowDetailPage.tsx
  • Modify: frontend/src/router.tsx

Step 1: Create the page

Create frontend/src/pages/MaintenanceFlowDetailPage.tsx:

import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Wrench, Calendar, Play, Settings, Clock, CheckCircle, AlertCircle } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import type { Tree, MaintenanceSchedule } from '@/types'
import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'

export default function MaintenanceFlowDetailPage() {
  const { id } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const [tree, setTree] = useState<Tree | null>(null)
  const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
  const [recentSessions, setRecentSessions] = useState<any[]>([])
  const [showBatchModal, setShowBatchModal] = useState(false)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    if (!id) return
    const load = async () => {
      try {
        const [treeData, sessionsData] = await Promise.all([
          treesApi.get(id),
          sessionsApi.list({ tree_id: id, size: 20 }).catch(() => []),
        ])
        setTree(treeData)
        setRecentSessions(sessionsData)

        // Try to load schedule (404 is fine — no schedule set yet)
        try {
          const sched = await maintenanceSchedulesApi.getForTree(id)
          setSchedule(sched)
        } catch {
          // No schedule yet
        }
      } catch {
        toast.error('Failed to load maintenance flow')
        navigate('/trees?type=maintenance')
      } finally {
        setIsLoading(false)
      }
    }
    load()
  }, [id, navigate])

  const handleLaunched = (_batchId: string, count: number) => {
    setShowBatchModal(false)
    toast.success(`${count} sessions created — check Sessions to run them`)
    navigate('/sessions')
  }

  if (isLoading) {
    return (
      <div className="flex h-64 items-center justify-center">
        <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
      </div>
    )
  }

  if (!tree) return null

  // Group sessions by batch_id
  const batches = recentSessions.reduce((acc: Record<string, any[]>, s: any) => {
    const key = s.batch_id ?? s.id
    if (!acc[key]) acc[key] = []
    acc[key].push(s)
    return acc
  }, {})
  const batchList = Object.entries(batches).slice(0, 10)

  return (
    <div className="mx-auto max-w-4xl space-y-6 p-6">
      {/* Header */}
      <div className="flex items-start justify-between">
        <div className="flex items-center gap-3">
          <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 text-amber-400">
            <Wrench className="h-5 w-5" />
          </div>
          <div>
            <h1 className="text-xl font-semibold text-foreground">{tree.name}</h1>
            <p className="text-[0.8125rem] text-muted-foreground">{tree.description}</p>
          </div>
        </div>
        <div className="flex gap-2">
          <button
            onClick={() => navigate(`/flows/${id}/edit`)}
            className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
          >
            <Settings className="h-3.5 w-3.5" />
            Edit Flow
          </button>
          <button
            onClick={() => setShowBatchModal(true)}
            className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
          >
            <Play className="h-3.5 w-3.5" />
            Batch Launch
          </button>
        </div>
      </div>

      {/* Schedule Panel */}
      <div className="rounded-xl border border-border bg-card p-5">
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-2">
            <Calendar className="h-4 w-4 text-muted-foreground" />
            <h2 className="font-semibold text-foreground">Schedule</h2>
          </div>
        </div>
        {schedule ? (
          <div className="mt-3 space-y-2 text-[0.875rem]">
            <div className="flex items-center gap-2">
              <span className={cn(
                "inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wide",
                schedule.is_active
                  ? "bg-emerald-500/10 text-emerald-400"
                  : "bg-muted text-muted-foreground"
              )}>
                {schedule.is_active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
                {schedule.is_active ? 'Active' : 'Paused'}
              </span>
              <span className="text-muted-foreground">·</span>
              <code className="rounded bg-accent px-1.5 py-0.5 text-[0.8125rem] text-foreground">{schedule.cron_expression}</code>
              <span className="text-muted-foreground">({schedule.timezone})</span>
            </div>
            {schedule.next_run_at && (
              <p className="text-muted-foreground">
                Next run: {new Date(schedule.next_run_at).toLocaleString()}
              </p>
            )}
          </div>
        ) : (
          <p className="mt-3 text-[0.875rem] text-muted-foreground">
            No schedule configured. Sessions can still be launched manually via Batch Launch.
          </p>
        )}
      </div>

      {/* Run History */}
      <div className="rounded-xl border border-border bg-card p-5">
        <div className="flex items-center gap-2 mb-4">
          <Clock className="h-4 w-4 text-muted-foreground" />
          <h2 className="font-semibold text-foreground">Run History</h2>
        </div>
        {batchList.length === 0 ? (
          <p className="text-[0.875rem] text-muted-foreground">No runs yet.</p>
        ) : (
          <div className="space-y-2">
            {batchList.map(([batchId, sessions]) => {
              const completed = sessions.filter((s: any) => s.completed_at).length
              const total = sessions.length
              const date = sessions[0]?.started_at
              return (
                <div key={batchId} className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
                  <div>
                    <p className="text-[0.875rem] font-medium text-foreground">
                      {total} target{total !== 1 ? 's' : ''}
                    </p>
                    {date && (
                      <p className="text-[0.8125rem] text-muted-foreground">
                        {new Date(date).toLocaleDateString()}
                      </p>
                    )}
                  </div>
                  <span className={cn(
                    "font-label text-[0.75rem] uppercase tracking-wide",
                    completed === total ? "text-emerald-400" : "text-amber-400"
                  )}>
                    {completed}/{total} complete
                  </span>
                </div>
              )
            })}
          </div>
        )}
      </div>

      {showBatchModal && (
        <BatchLaunchModal
          treeId={id!}
          treeName={tree.name}
          onClose={() => setShowBatchModal(false)}
          onLaunched={handleLaunched}
        />
      )}
    </div>
  )
}

Step 2: Register the route

In frontend/src/router.tsx, add after the existing ProceduralNavigationPage import:

const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage'))

Then in the routes array, inside the ProtectedRoute/AppLayout children, add alongside the existing /flows/:id/navigate route:

{
  path: '/flows/:id/maintenance',
  element: (
    <Suspense fallback={<PageLoader />}>
      <MaintenanceFlowDetailPage />
    </Suspense>
  ),
},

Step 3: Build check

cd frontend && npm run build

Expected: No errors.

Step 4: Commit

git add frontend/src/pages/MaintenanceFlowDetailPage.tsx frontend/src/router.tsx
git commit -m "feat: add MaintenanceFlowDetailPage with schedule panel, batch launch, and run history"

Phase 3: APScheduler Integration

Task 9: Scheduler core + auto-session creation

Files:

  • Create: backend/app/core/scheduler.py
  • Modify: backend/app/main.py

Step 1: Create the scheduler module

Create backend/app/core/scheduler.py:

"""APScheduler integration for maintenance flow auto-session creation."""
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import pytz
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker

from app.core.config import settings
from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.session import Session
from app.models.target_list import TargetList
from app.models.tree import Tree

logger = logging.getLogger(__name__)

scheduler = AsyncIOScheduler()


def _make_session_factory():
    engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)
    return async_sessionmaker(engine, expire_on_commit=False)


async def _fire_maintenance_schedule(schedule_id: str) -> None:
    """Create batch sessions for a scheduled maintenance run."""
    session_factory = _make_session_factory()
    async with session_factory() as db:
        try:
            result = await db.execute(
                select(MaintenanceSchedule).where(
                    MaintenanceSchedule.id == uuid.UUID(schedule_id),
                    MaintenanceSchedule.is_active == True,
                )
            )
            schedule = result.scalar_one_or_none()
            if not schedule:
                logger.warning(f"Schedule {schedule_id} not found or inactive, skipping")
                return

            tree_result = await db.execute(
                select(Tree).where(Tree.id == schedule.tree_id)
            )
            tree = tree_result.scalar_one_or_none()
            if not tree:
                logger.error(f"Tree {schedule.tree_id} not found for schedule {schedule_id}")
                return

            # Resolve targets
            targets: list[dict] = []
            if schedule.target_list_id:
                list_result = await db.execute(
                    select(TargetList).where(TargetList.id == schedule.target_list_id)
                )
                target_list = list_result.scalar_one_or_none()
                if target_list:
                    targets = target_list.targets

            if not targets:
                logger.info(f"Schedule {schedule_id} has no targets — creating single session without target")
                targets = [{"label": "Unassigned"}]

            batch_id = uuid.uuid4()
            created_count = 0

            for target in targets:
                session = Session(
                    tree_id=tree.id,
                    user_id=schedule.created_by,  # attribute to the schedule creator
                    tree_snapshot=tree.tree_structure,
                    path_taken=[],
                    decisions=[],
                    custom_steps=[],
                    session_variables={},
                    batch_id=batch_id,
                    target_label=target.get("label", ""),
                )
                db.add(session)
                created_count += 1

            # Update schedule tracking
            schedule.last_run_at = datetime.now(timezone.utc)
            # Compute next_run_at
            from croniter import croniter
            tz = pytz.timezone(schedule.timezone)
            now = datetime.now(tz)
            cron = croniter(schedule.cron_expression, now)
            schedule.next_run_at = cron.get_next(datetime).astimezone(timezone.utc)

            await db.commit()
            logger.info(
                f"Schedule {schedule_id} fired: created {created_count} sessions "
                f"(batch {batch_id}) for tree '{tree.name}'"
            )
        except Exception:
            logger.exception(f"Error firing maintenance schedule {schedule_id}")
            await db.rollback()


async def load_all_schedules(db: AsyncSession) -> None:
    """Load all active schedules into APScheduler on startup."""
    result = await db.execute(
        select(MaintenanceSchedule).where(MaintenanceSchedule.is_active == True)
    )
    schedules = result.scalars().all()
    for schedule in schedules:
        register_schedule(schedule)
    logger.info(f"Loaded {len(schedules)} maintenance schedule(s)")


def register_schedule(schedule: MaintenanceSchedule) -> None:
    """Register or update a schedule in APScheduler."""
    job_id = f"maintenance_{schedule.id}"
    try:
        tz = pytz.timezone(schedule.timezone)
        trigger = CronTrigger.from_crontab(schedule.cron_expression, timezone=tz)
        if scheduler.get_job(job_id):
            scheduler.reschedule_job(job_id, trigger=trigger)
        else:
            scheduler.add_job(
                _fire_maintenance_schedule,
                trigger=trigger,
                id=job_id,
                args=[str(schedule.id)],
                replace_existing=True,
                misfire_grace_time=3600,  # 1 hour grace for missed fires
            )
        logger.info(f"Registered schedule {schedule.id} ({schedule.cron_expression})")
    except Exception:
        logger.exception(f"Failed to register schedule {schedule.id}")


def unregister_schedule(schedule_id: str) -> None:
    """Remove a schedule from APScheduler."""
    job_id = f"maintenance_{schedule_id}"
    if scheduler.get_job(job_id):
        scheduler.remove_job(job_id)
        logger.info(f"Unregistered schedule {schedule_id}")

Step 2: Wire scheduler into FastAPI lifespan

In backend/app/main.py, update the lifespan handler:

from app.core.scheduler import scheduler, load_all_schedules
from app.core.database import get_db_context  # or however you get a DB session outside a request

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifespan handler."""
    logger.info("Starting ResolutionFlow API server...")
    logger.info(f"Environment: {'Development' if settings.DEBUG else 'Production'}")
    logger.info(f"ALLOW_RAILWAY_ORIGINS: {settings.ALLOW_RAILWAY_ORIGINS}")

    # Start scheduler and load active maintenance schedules
    scheduler.start()
    from app.core.database import async_session_factory  # import your session factory
    async with async_session_factory() as db:
        await load_all_schedules(db)

    yield

    # Shutdown
    scheduler.shutdown(wait=False)
    logger.info("Shutting down ResolutionFlow API server...")

Note: Check backend/app/core/database.py for the correct session factory name. It may be AsyncSessionLocal, async_session, etc. Use whatever name is already exported there. If there's no standalone session factory export, add one:

async_session_factory = async_sessionmaker(engine, expire_on_commit=False)

Step 3: Wire register/unregister into schedule endpoints

In backend/app/api/endpoints/maintenance_schedules.py, import and call the scheduler functions when a schedule is created, updated, or toggled:

After the create_schedule endpoint commits, add:

from app.core.scheduler import register_schedule
register_schedule(schedule)

After the update_schedule endpoint commits, add:

from app.core.scheduler import register_schedule, unregister_schedule
if schedule.is_active:
    register_schedule(schedule)
else:
    unregister_schedule(str(schedule.id))

Step 4: Verify scheduler starts cleanly

Start the backend:

cd backend && uvicorn app.main:app --reload

Expected in logs: Loaded N maintenance schedule(s) with no exceptions.

Step 5: Commit

git add backend/app/core/scheduler.py backend/app/main.py backend/app/api/endpoints/maintenance_schedules.py
git commit -m "feat: APScheduler integration for maintenance flow auto-session creation"

Phase 4: Target Lists Settings Page

Task 10: TargetListsPage + account routing

Files:

  • Create: frontend/src/pages/account/TargetListsPage.tsx
  • Modify: frontend/src/router.tsx
  • Modify: frontend/src/components/account/AccountLayout.tsx (add nav link)

Step 1: Create the settings page

Create frontend/src/pages/account/TargetListsPage.tsx:

import { useEffect, useState } from 'react'
import { Plus, Pencil, Trash2, Server } from 'lucide-react'
import { targetListsApi } from '@/api'
import type { TargetList, TargetListCreate, TargetEntry } from '@/types'
import { toast } from '@/lib/toast'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'

export default function TargetListsPage() {
  const [lists, setLists] = useState<TargetList[]>([])
  const [isLoading, setIsLoading] = useState(true)
  const [showEditor, setShowEditor] = useState(false)
  const [editingList, setEditingList] = useState<TargetList | null>(null)
  const [deleteTarget, setDeleteTarget] = useState<TargetList | null>(null)

  // Editor state
  const [editorName, setEditorName] = useState('')
  const [editorDescription, setEditorDescription] = useState('')
  const [editorTargets, setEditorTargets] = useState('')
  const [isSaving, setIsSaving] = useState(false)

  const load = async () => {
    try {
      const data = await targetListsApi.list()
      setLists(data)
    } catch {
      toast.error('Failed to load target lists')
    } finally {
      setIsLoading(false)
    }
  }

  useEffect(() => { load() }, [])

  const openEditor = (list?: TargetList) => {
    if (list) {
      setEditingList(list)
      setEditorName(list.name)
      setEditorDescription(list.description ?? '')
      setEditorTargets(list.targets.map(t => t.notes ? `${t.label} # ${t.notes}` : t.label).join('\n'))
    } else {
      setEditingList(null)
      setEditorName('')
      setEditorDescription('')
      setEditorTargets('')
    }
    setShowEditor(true)
  }

  const parseTargets = (raw: string): TargetEntry[] =>
    raw.split('\n')
      .map(l => l.trim())
      .filter(Boolean)
      .map(line => {
        const [label, ...rest] = line.split('#')
        return { label: label.trim(), notes: rest.join('#').trim() || undefined }
      })

  const handleSave = async () => {
    const targets = parseTargets(editorTargets)
    if (!editorName.trim()) { toast.error('Name is required'); return }
    if (targets.length === 0) { toast.error('Add at least one target'); return }

    setIsSaving(true)
    try {
      const payload: TargetListCreate = {
        name: editorName.trim(),
        description: editorDescription.trim() || undefined,
        targets,
      }
      if (editingList) {
        const updated = await targetListsApi.update(editingList.id, payload)
        setLists(prev => prev.map(l => l.id === updated.id ? updated : l))
        toast.success('Target list updated')
      } else {
        const created = await targetListsApi.create(payload)
        setLists(prev => [...prev, created])
        toast.success('Target list created')
      }
      setShowEditor(false)
    } catch {
      toast.error('Failed to save target list')
    } finally {
      setIsSaving(false)
    }
  }

  const handleDelete = async () => {
    if (!deleteTarget) return
    try {
      await targetListsApi.delete(deleteTarget.id)
      setLists(prev => prev.filter(l => l.id !== deleteTarget.id))
      toast.success('Target list deleted')
    } catch {
      toast.error('Failed to delete target list')
    } finally {
      setDeleteTarget(null)
    }
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-xl font-semibold text-foreground">Target Lists</h1>
          <p className="text-[0.8125rem] text-muted-foreground">Saved server lists for maintenance flow batch launching</p>
        </div>
        <button
          onClick={() => openEditor()}
          className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
        >
          <Plus className="h-4 w-4" />
          New List
        </button>
      </div>

      {isLoading ? (
        <div className="flex h-32 items-center justify-center">
          <div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
        </div>
      ) : lists.length === 0 ? (
        <div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-12 text-center">
          <Server className="mb-3 h-10 w-10 text-muted-foreground" />
          <p className="font-medium text-foreground">No target lists yet</p>
          <p className="mt-1 text-[0.8125rem] text-muted-foreground">Create a list of servers to reuse across maintenance runs</p>
        </div>
      ) : (
        <div className="space-y-3">
          {lists.map(list => (
            <div key={list.id} className="flex items-center justify-between rounded-xl border border-border bg-card px-5 py-4">
              <div>
                <p className="font-medium text-foreground">{list.name}</p>
                {list.description && (
                  <p className="text-[0.8125rem] text-muted-foreground">{list.description}</p>
                )}
                <p className="mt-0.5 font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
                  {list.targets.length} target{list.targets.length !== 1 ? 's' : ''} · {list.targets.slice(0, 3).map(t => t.label).join(', ')}{list.targets.length > 3 ? '...' : ''}
                </p>
              </div>
              <div className="flex items-center gap-1">
                <button onClick={() => openEditor(list)} className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground">
                  <Pencil className="h-4 w-4" />
                </button>
                <button onClick={() => setDeleteTarget(list)} className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-red-400">
                  <Trash2 className="h-4 w-4" />
                </button>
              </div>
            </div>
          ))}
        </div>
      )}

      {/* Editor Modal */}
      {showEditor && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
          <div className="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-2xl">
            <h2 className="mb-4 text-base font-semibold text-foreground">
              {editingList ? 'Edit Target List' : 'New Target List'}
            </h2>
            <div className="space-y-4">
              <div>
                <label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">Name</label>
                <input
                  type="text"
                  value={editorName}
                  onChange={e => setEditorName(e.target.value)}
                  className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
                  placeholder="e.g. RDS Farm A"
                />
              </div>
              <div>
                <label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">Description (optional)</label>
                <input
                  type="text"
                  value={editorDescription}
                  onChange={e => setEditorDescription(e.target.value)}
                  className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
                  placeholder="e.g. Production RDS servers"
                />
              </div>
              <div>
                <label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
                  Targets (one per line  optionally add notes after #)
                </label>
                <textarea
                  value={editorTargets}
                  onChange={e => setEditorTargets(e.target.value)}
                  rows={6}
                  className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
                  placeholder={"RDS-01 # 192.168.1.10\nRDS-02\nRDS-03 # Backup server"}
                />
              </div>
            </div>
            <div className="mt-6 flex justify-end gap-2">
              <button onClick={() => setShowEditor(false)} className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground">Cancel</button>
              <button onClick={handleSave} disabled={isSaving} className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50">
                {isSaving ? 'Saving...' : 'Save'}
              </button>
            </div>
          </div>
        </div>
      )}

      {deleteTarget && (
        <ConfirmDialog
          title="Delete Target List"
          message={`Delete "${deleteTarget.name}"? This cannot be undone.`}
          confirmLabel="Delete"
          onConfirm={handleDelete}
          onCancel={() => setDeleteTarget(null)}
          destructive
        />
      )}
    </div>
  )
}

Step 2: Register the route

In frontend/src/router.tsx, add:

const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))

Then inside the account section routes (alongside TeamCategoriesPage):

{
  path: '/account/target-lists',
  element: (
    <Suspense fallback={<PageLoader />}>
      <TargetListsPage />
    </Suspense>
  ),
},

Step 3: Add nav link in AccountLayout

Open frontend/src/components/account/AccountLayout.tsx. Find the nav links and add:

<NavLink to="/account/target-lists">Target Lists</NavLink>

(Match the exact pattern used for existing links in that file.)

Step 4: Build check

cd frontend && npm run build

Expected: No errors.

Step 5: Run all backend tests one final time

cd backend && pytest --override-ini="addopts="

Expected: All pass.

Step 6: Final commit

git add frontend/src/pages/account/TargetListsPage.tsx frontend/src/router.tsx \
    frontend/src/components/account/AccountLayout.tsx
git commit -m "feat: add Target Lists settings page under Team account"

Summary

Phase Tasks Key deliverables
1 — DB + API 14 maintenance tree_type, target_lists table, maintenance_schedules table, batch session endpoint
2 — Frontend 58 Types/API clients, sidebar nav, library filter, BatchLaunchModal, MaintenanceFlowDetailPage
3 — Scheduler 9 APScheduler, auto-session creation, schedule lifecycle wired to API
4 — Settings 10 TargetListsPage in account settings

Each task ends with a commit. Each phase is independently deployable.