commit 37b2c21720383b5be0a5c24b550448d7d20c998f Author: caoimhinr Date: Mon May 25 19:23:07 2026 +0200 feat: initial corvid — standalone ticket service extracted from homelab-dashboard Extracts the ticket/attachment system into a reusable pip-installable package. Centralises homelab MCP server (tickets, attachments, system logs) here. - corvid package: models, router (make_router factory), config, standalone main - Alembic migration chain for fresh standalone deployments - Docker Compose for standalone deployment (port 8090) - mcp_server.py: moved from homelab-dashboard, CORVID_REPO_PATH configurable Co-Authored-By: Claude Sonnet 4.6 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..65cc591 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# corvid + +Standalone ticket-management service for AI agents. FastAPI + Postgres + MCP server. + +Extracted from `homelab-dashboard` — the `homelab-dashboard` app continues to use +corvid as a pip package (Option A integration). + +## Package + +``` +corvid/ + models.py — Ticket, TicketAttachment, Base (SQLAlchemy ORM) + router.py — make_router(api_key, get_db, require_user=None) → APIRouter + config.py — DATABASE_URL, TICKETS_API_KEY env vars + main.py — standalone FastAPI app (no web UI) + database.py — engine, get_db +alembic/ — migration chain for standalone deployments +mcp_server.py — FastMCP server; primary agent interface +``` + +## MCP server + +`mcp_server.py` is the centralized homelab MCP server. It exposes: +- Ticket CRUD + attachments (via `HOMEDASH_URL` API) +- Homelab system logs (`/api/logs`) + +Configure in `~/.claude/mcp.json`: + +```json +"homelab-tickets": { + "command": "python3", + "args": ["/home/caoimhinr/Projects/corvid/mcp_server.py"], + "env": { + "HOMEDASH_URL": "https://homedash.welvaert.org", + "HOMEDASH_API_KEY": "" + } +} +``` + +`TICKETS_API_KEY` value is in `pve/ansible/host_vars/hetzner.yml` (gitignored). + +## Standalone deployment + +```bash +cp .env.example .env # set DATABASE_URL and TICKETS_API_KEY +docker compose up -d +``` + +Default port: `8090` (maps to internal `8080`). + +## homelab-dashboard integration + +The dashboard depends on corvid as an editable pip install: + +```bash +# Local dev +pip install -e ~/Projects/corvid + +# VPS deploy — corvid is rsynced alongside the dashboard, then installed +rsync -az ~/Projects/corvid/ root@157.180.29.73:/opt/corvid/ +ssh root@157.180.29.73 'pip install -e /opt/corvid' +``` + +The dashboard's `Dockerfile` copies corvid source before installing: + +```dockerfile +COPY ../corvid/ /tmp/corvid/ # requires parent-dir build context +RUN pip install --no-cache-dir /tmp/corvid/ +``` + +See `homelab-dashboard/CLAUDE.md` for the full deploy procedure. + +## Local dev + +```bash +pip install -e ".[dev,mcp]" +TICKETS_API_KEY="" DATABASE_URL="sqlite+aiosqlite:///dev.db" \ + uvicorn corvid.main:app --reload --port 8090 +``` + +## DB migrations + +Alembic runs at startup (`entrypoint.sh`). Add new migrations in +`alembic/versions/` following the `NNNN_.py` naming convention. + +The dashboard keeps its own separate migration chain — corvid's migrations +are only for fresh standalone deployments. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5272f4a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY pyproject.toml . +RUN pip install --no-cache-dir -e ".[mcp]" + +COPY alembic.ini . +COPY alembic/ alembic/ +COPY corvid/ corvid/ +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +EXPOSE 8080 +CMD ["./entrypoint.sh"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d7a114a --- /dev/null +++ b/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +file_template = %%(version_num)s_%%(slug)s +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..1a13d02 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,33 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy.ext.asyncio import create_async_engine + +from alembic import context + +alembic_cfg = context.config + +if alembic_cfg.config_file_name is not None: + fileConfig(alembic_cfg.config_file_name) + +from corvid import config as app_config +from corvid.models import Base # noqa: E402 — registers models + +target_metadata = Base.metadata + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations(): + engine = create_async_engine(app_config.DATABASE_URL) + async with engine.begin() as conn: + await conn.run_sync(do_run_migrations) + await engine.dispose() + + +run_migrations_online = lambda: asyncio.run(run_async_migrations()) +run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_tickets_schema.py b/alembic/versions/0001_tickets_schema.py new file mode 100644 index 0000000..e8e7718 --- /dev/null +++ b/alembic/versions/0001_tickets_schema.py @@ -0,0 +1,53 @@ +"""tickets and ticket_attachments initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-05-25 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "tickets", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("guid", sa.String(36), nullable=False, unique=True), + sa.Column("user", sa.String(64), nullable=False, server_default="kevin"), + sa.Column("title", sa.String(256), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("status", sa.String(16), nullable=False, server_default="open"), + sa.Column("type", sa.String(16), nullable=False, server_default="feature"), + sa.Column("parent_id", sa.Integer(), + sa.ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True), + sa.Column("effort", sa.Integer(), nullable=True), + sa.Column("startup_script", sa.Text(), nullable=True), + ) + op.create_index("ix_tickets_parent_id", "tickets", ["parent_id"]) + + op.create_table( + "ticket_attachments", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("ticket_id", sa.Integer(), + sa.ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(256), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("created_by", sa.String(64), nullable=False, server_default="kevin"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_ticket_attachments_ticket_id", "ticket_attachments", ["ticket_id"]) + + +def downgrade() -> None: + op.drop_index("ix_ticket_attachments_ticket_id", table_name="ticket_attachments") + op.drop_table("ticket_attachments") + op.drop_index("ix_tickets_parent_id", table_name="tickets") + op.drop_table("tickets") diff --git a/corvid.egg-info/PKG-INFO b/corvid.egg-info/PKG-INFO new file mode 100644 index 0000000..4a49e85 --- /dev/null +++ b/corvid.egg-info/PKG-INFO @@ -0,0 +1,18 @@ +Metadata-Version: 2.4 +Name: corvid +Version: 0.1.0 +Summary: Standalone ticket management service for AI agents +Requires-Python: >=3.12 +Requires-Dist: fastapi>=0.115 +Requires-Dist: sqlalchemy[asyncio]>=2.0 +Requires-Dist: asyncpg>=0.30 +Requires-Dist: uvicorn[standard]>=0.32 +Requires-Dist: alembic>=1.14 +Requires-Dist: pydantic>=2.0 +Provides-Extra: mcp +Requires-Dist: mcp[cli]>=1.0; extra == "mcp" +Requires-Dist: httpx>=0.28; extra == "mcp" +Provides-Extra: dev +Requires-Dist: pytest>=8.0; extra == "dev" +Requires-Dist: pytest-asyncio>=0.24; extra == "dev" +Requires-Dist: aiosqlite>=0.20; extra == "dev" diff --git a/corvid.egg-info/SOURCES.txt b/corvid.egg-info/SOURCES.txt new file mode 100644 index 0000000..8d3bb6b --- /dev/null +++ b/corvid.egg-info/SOURCES.txt @@ -0,0 +1,12 @@ +pyproject.toml +corvid/__init__.py +corvid/config.py +corvid/database.py +corvid/main.py +corvid/models.py +corvid/router.py +corvid.egg-info/PKG-INFO +corvid.egg-info/SOURCES.txt +corvid.egg-info/dependency_links.txt +corvid.egg-info/requires.txt +corvid.egg-info/top_level.txt \ No newline at end of file diff --git a/corvid.egg-info/dependency_links.txt b/corvid.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/corvid.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/corvid.egg-info/requires.txt b/corvid.egg-info/requires.txt new file mode 100644 index 0000000..c0867f5 --- /dev/null +++ b/corvid.egg-info/requires.txt @@ -0,0 +1,15 @@ +fastapi>=0.115 +sqlalchemy[asyncio]>=2.0 +asyncpg>=0.30 +uvicorn[standard]>=0.32 +alembic>=1.14 +pydantic>=2.0 + +[dev] +pytest>=8.0 +pytest-asyncio>=0.24 +aiosqlite>=0.20 + +[mcp] +mcp[cli]>=1.0 +httpx>=0.28 diff --git a/corvid.egg-info/top_level.txt b/corvid.egg-info/top_level.txt new file mode 100644 index 0000000..0f747bd --- /dev/null +++ b/corvid.egg-info/top_level.txt @@ -0,0 +1 @@ +corvid diff --git a/corvid/__init__.py b/corvid/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/corvid/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/corvid/__pycache__/__init__.cpython-312.pyc b/corvid/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7f46b57 Binary files /dev/null and b/corvid/__pycache__/__init__.cpython-312.pyc differ diff --git a/corvid/__pycache__/config.cpython-312.pyc b/corvid/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..ded83f1 Binary files /dev/null and b/corvid/__pycache__/config.cpython-312.pyc differ diff --git a/corvid/__pycache__/database.cpython-312.pyc b/corvid/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..0eb73c0 Binary files /dev/null and b/corvid/__pycache__/database.cpython-312.pyc differ diff --git a/corvid/__pycache__/main.cpython-312.pyc b/corvid/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..17e15a9 Binary files /dev/null and b/corvid/__pycache__/main.cpython-312.pyc differ diff --git a/corvid/__pycache__/models.cpython-312.pyc b/corvid/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..2f27263 Binary files /dev/null and b/corvid/__pycache__/models.cpython-312.pyc differ diff --git a/corvid/__pycache__/router.cpython-312.pyc b/corvid/__pycache__/router.cpython-312.pyc new file mode 100644 index 0000000..fe11695 Binary files /dev/null and b/corvid/__pycache__/router.cpython-312.pyc differ diff --git a/corvid/config.py b/corvid/config.py new file mode 100644 index 0000000..d57fed5 --- /dev/null +++ b/corvid/config.py @@ -0,0 +1,4 @@ +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://corvid:corvid@postgres:5432/corvid") +TICKETS_API_KEY = os.getenv("TICKETS_API_KEY", "") diff --git a/corvid/database.py b/corvid/database.py new file mode 100644 index 0000000..0530b6e --- /dev/null +++ b/corvid/database.py @@ -0,0 +1,12 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from .config import DATABASE_URL +from .models import Base # noqa: F401 — re-exported for alembic + +engine = create_async_engine(DATABASE_URL, pool_pre_ping=True) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session diff --git a/corvid/main.py b/corvid/main.py new file mode 100644 index 0000000..3b63ddd --- /dev/null +++ b/corvid/main.py @@ -0,0 +1,22 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from .config import TICKETS_API_KEY +from .database import get_db +from .router import make_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + + +app = FastAPI(title="Corvid", lifespan=lifespan) + +app.include_router(make_router(api_key=TICKETS_API_KEY, get_db=get_db)) + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/corvid/models.py b/corvid/models.py new file mode 100644 index 0000000..3a9512f --- /dev/null +++ b/corvid/models.py @@ -0,0 +1,53 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class TicketAttachment(Base): + __tablename__ = "ticket_attachments" + + id: Mapped[int] = mapped_column(primary_key=True) + ticket_id: Mapped[int] = mapped_column(ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False) + title: Mapped[str] = mapped_column(String(256), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="kevin") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc)) + + ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="attachments") + + +class Ticket(Base): + __tablename__ = "tickets" + + id: Mapped[int] = mapped_column(primary_key=True) + guid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, + default=lambda: str(uuid.uuid4())) + user: Mapped[str] = mapped_column(String(64), nullable=False, default="kevin") + title: Mapped[str] = mapped_column(String(256), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc)) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") + type: Mapped[str] = mapped_column(String(16), nullable=False, default="feature") + parent_id: Mapped[int | None] = mapped_column(ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True) + effort: Mapped[int | None] = mapped_column(Integer, nullable=True) + startup_script: Mapped[str | None] = mapped_column(Text, nullable=True) + + attachments: Mapped[list["TicketAttachment"]] = relationship( + "TicketAttachment", + back_populates="ticket", + cascade="all, delete-orphan", + order_by="TicketAttachment.created_at", + lazy="selectin", + ) + + @property + def human_id(self) -> str: + return f"HOME-{self.id:03d}" diff --git a/corvid/router.py b/corvid/router.py new file mode 100644 index 0000000..f467bf3 --- /dev/null +++ b/corvid/router.py @@ -0,0 +1,304 @@ +import uuid + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request +from pydantic import BaseModel +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from .models import Ticket, TicketAttachment + +VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"} +VALID_TYPES = {"feature", "bug", "chore", "project"} +VALID_USERS = {"kevin", "claude"} + + +class TicketCreate(BaseModel): + user: str = "kevin" + title: str + description: str | None = None + status: str = "open" + type: str = "feature" + parent_id: int | None = None + effort: int | None = None + startup_script: str | None = None + + +class TicketUpdate(BaseModel): + user: str | None = None + title: str | None = None + description: str | None = None + status: str | None = None + type: str | None = None + parent_id: int | None = None + effort: int | None = None + startup_script: str | None = None + + +class AttachmentCreate(BaseModel): + title: str + content: str + created_by: str = "kevin" + + +class AttachmentUpdate(BaseModel): + title: str | None = None + content: str | None = None + + +async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict: + child_ids = (await db.execute( + select(Ticket.id).where(Ticket.parent_id == t.id).order_by(Ticket.id) + )).scalars().all() + return { + "id": t.id, + "human_id": t.human_id, + "guid": t.guid, + "user": t.user, + "title": t.title, + "description": t.description, + "submitted_at": t.submitted_at.isoformat() if t.submitted_at else None, + "status": t.status, + "type": t.type, + "parent_id": t.parent_id, + "child_ids": list(child_ids), + "attachment_count": len(t.attachments), + "effort": t.effort, + "startup_script": t.startup_script, + } + + +def _att_dict(a: TicketAttachment) -> dict: + return { + "id": a.id, + "ticket_id": a.ticket_id, + "title": a.title, + "content": a.content, + "created_by": a.created_by, + "created_at": a.created_at.isoformat() if a.created_at else None, + } + + +async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket: + t = (await db.execute(select(Ticket).where(Ticket.id == ticket_id))).scalar_one_or_none() + if not t: + raise HTTPException(404, "ticket not found") + return t + + +def make_router(api_key: str, get_db, require_user=None) -> APIRouter: + """ + Return a FastAPI APIRouter with all /api/tickets/* endpoints. + + Args: + api_key: Value of TICKETS_API_KEY — requests presenting this in + X-Api-Key are authenticated as the service account. + get_db: FastAPI dependency yielding an AsyncSession. + require_user: Optional FastAPI dependency / callable(request) that + returns a user dict or raises. When provided, web + sessions are accepted alongside the API key. When + omitted, only API-key auth is accepted. + """ + router = APIRouter() + + def _api_key_ok(x_api_key: str | None) -> bool: + return bool(api_key and x_api_key == api_key) + + def require_ticket_auth( + request: Request, + x_api_key: str | None = Header(default=None), + ): + if _api_key_ok(x_api_key): + return {"username": "claude", "email": "claude@internal"} + if require_user is not None: + return require_user(request) + raise HTTPException(401, "unauthorized") + + # ── Ticket endpoints ────────────────────────────────────────────────────── + + @router.get("/api/tickets") + async def list_tickets( + status: list[str] = Query(default=[]), + type: list[str] = Query(default=[]), + q: str = Query(default=""), + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + stmt = select(Ticket).order_by(Ticket.submitted_at.desc()) + if status: + stmt = stmt.where(Ticket.status.in_(status)) + if type: + stmt = stmt.where(Ticket.type.in_(type)) + if q: + term = f"%{q}%" + stmt = stmt.where(or_(Ticket.title.ilike(term), Ticket.description.ilike(term))) + rows = (await db.execute(stmt)).scalars().all() + return [await _ticket_dict(t, db) for t in rows] + + @router.post("/api/tickets", status_code=201) + async def create_ticket( + body: TicketCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + if body.status not in VALID_STATUSES: + raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}") + if body.type not in VALID_TYPES: + raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}") + if body.user not in VALID_USERS: + raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}") + if body.effort is not None and not (1 <= body.effort <= 10): + raise HTTPException(400, "effort must be between 1 and 10") + if body.parent_id is not None: + await _get_ticket_or_404(db, body.parent_id) + t = Ticket( + guid=str(uuid.uuid4()), + user=body.user, + title=body.title.strip(), + description=body.description, + status=body.status, + type=body.type, + parent_id=body.parent_id, + effort=body.effort, + startup_script=body.startup_script, + ) + db.add(t) + await db.commit() + return await _ticket_dict(await _get_ticket_or_404(db, t.id), db) + + @router.get("/api/tickets/{ticket_id}") + async def get_ticket( + ticket_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db) + + @router.patch("/api/tickets/{ticket_id}") + async def update_ticket( + ticket_id: int, + body: TicketUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + t = await _get_ticket_or_404(db, ticket_id) + if body.status is not None: + if body.status not in VALID_STATUSES: + raise HTTPException(400, f"status must be one of {sorted(VALID_STATUSES)}") + t.status = body.status + if body.type is not None: + if body.type not in VALID_TYPES: + raise HTTPException(400, f"type must be one of {sorted(VALID_TYPES)}") + t.type = body.type + if body.user is not None: + if body.user not in VALID_USERS: + raise HTTPException(400, f"user must be one of {sorted(VALID_USERS)}") + t.user = body.user + if body.title is not None: + t.title = body.title.strip() + if body.description is not None: + t.description = body.description + if "parent_id" in body.model_fields_set: + if body.parent_id is not None: + if body.parent_id == ticket_id: + raise HTTPException(400, "ticket cannot be its own parent") + await _get_ticket_or_404(db, body.parent_id) + t.parent_id = body.parent_id + if "effort" in body.model_fields_set: + if body.effort is not None and not (1 <= body.effort <= 10): + raise HTTPException(400, "effort must be between 1 and 10") + t.effort = body.effort + if "startup_script" in body.model_fields_set: + t.startup_script = body.startup_script + await db.commit() + return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db) + + @router.delete("/api/tickets/{ticket_id}") + async def delete_ticket( + ticket_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + t = await _get_ticket_or_404(db, ticket_id) + await db.delete(t) + await db.commit() + return {"ok": True} + + # ── Attachment endpoints ────────────────────────────────────────────────── + + @router.get("/api/tickets/{ticket_id}/attachments") + async def list_attachments( + ticket_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + await _get_ticket_or_404(db, ticket_id) + rows = (await db.execute( + select(TicketAttachment) + .where(TicketAttachment.ticket_id == ticket_id) + .order_by(TicketAttachment.created_at) + )).scalars().all() + return [_att_dict(a) for a in rows] + + @router.post("/api/tickets/{ticket_id}/attachments", status_code=201) + async def create_attachment( + ticket_id: int, + body: AttachmentCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + await _get_ticket_or_404(db, ticket_id) + if not body.title.strip(): + raise HTTPException(400, "title is required") + if not body.content.strip(): + raise HTTPException(400, "content is required") + if body.created_by not in VALID_USERS: + raise HTTPException(400, f"created_by must be one of {sorted(VALID_USERS)}") + a = TicketAttachment( + ticket_id=ticket_id, + title=body.title.strip(), + content=body.content, + created_by=body.created_by, + ) + db.add(a) + await db.commit() + await db.refresh(a) + return _att_dict(a) + + @router.patch("/api/tickets/{ticket_id}/attachments/{att_id}") + async def update_attachment( + ticket_id: int, + att_id: int, + body: AttachmentUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + a = await db.get(TicketAttachment, att_id) + if not a or a.ticket_id != ticket_id: + raise HTTPException(404, "attachment not found") + if body.title is not None: + if not body.title.strip(): + raise HTTPException(400, "title cannot be empty") + a.title = body.title.strip() + if body.content is not None: + if not body.content.strip(): + raise HTTPException(400, "content cannot be empty") + a.content = body.content + await db.commit() + await db.refresh(a) + return _att_dict(a) + + @router.delete("/api/tickets/{ticket_id}/attachments/{att_id}") + async def delete_attachment( + ticket_id: int, + att_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + a = await db.get(TicketAttachment, att_id) + if not a or a.ticket_id != ticket_id: + raise HTTPException(404, "attachment not found") + await db.delete(a) + await db.commit() + return {"ok": True} + + return router diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f158e1b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: corvid + POSTGRES_PASSWORD: corvid + POSTGRES_DB: corvid + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U corvid"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: . + ports: + - "8090:8080" + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + +volumes: + pgdata: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..2b9f254 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e +alembic upgrade head +exec uvicorn corvid.main:app --host 0.0.0.0 --port 8080 diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..c9308c2 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +Corvid MCP server — homelab agent tools. + +Provides ticket management and homelab monitoring tools for Claude Code agents. + +Configure in ~/.claude/mcp.json: + "homelab-tickets": { + "command": "python3", + "args": ["/home/caoimhinr/Projects/corvid/mcp_server.py"], + "env": { + "HOMEDASH_URL": "https://homedash.welvaert.org", + "HOMEDASH_API_KEY": "" + } + } +""" + +import os + +import httpx +from mcp.server.fastmcp import FastMCP + +HOMEDASH_URL = os.environ.get("HOMEDASH_URL", "http://localhost:8080").rstrip("/") +API_KEY = os.environ.get("HOMEDASH_API_KEY", "") + +mcp = FastMCP("homelab-tickets") + +_HEADERS = {"X-Api-Key": API_KEY, "Content-Type": "application/json"} + +# Repo root used in generated startup scripts (cd here before running claude) +_REPO_PATH = os.environ.get("CORVID_REPO_PATH", + os.path.expanduser("~/Projects/corvid")) + +# MCP tool names always included so the agent can read and update its own ticket +_BASE_TOOLS = [ + "Read", + "mcp__homelab-tickets__get_ticket", + "mcp__homelab-tickets__update_ticket", + "mcp__homelab-tickets__add_attachment", + "mcp__homelab-tickets__list_attachments", +] + +_HA_KEYWORDS = ( + "home assistant", " ha ", "climate", "temperature", "thermostat", + "light", "sensor", "entity", "automation", "scene", +) +_WEB_KEYWORDS = ( + "research", "investigate", "look up", "documentation", "readme", + " api ", "spec ", "rfc", +) + + +def _generate_startup_script(ticket_id: int, human_id: str, title: str, + description: str | None, ticket_type: str) -> str: + text = f"{title} {description or ''}".lower() + tools = list(_BASE_TOOLS) + if ticket_type in ("feature", "bug", "chore"): + tools += ["Edit", "Write", "Bash"] + if any(kw in text for kw in _WEB_KEYWORDS): + tools += ["WebSearch", "WebFetch"] + if any(kw in text for kw in _HA_KEYWORDS): + tools += [ + "mcp__homeassistant__GetLiveContext", + "mcp__homeassistant__HassTurnOn", + "mcp__homeassistant__HassTurnOff", + "mcp__homeassistant__HassLightSet", + "mcp__homeassistant__HassClimateSetTemperature", + ] + tools_str = ",".join(tools) + prompt = ( + f"complete ticket {human_id}: {title}. " + f"Start by calling mcp__homelab-tickets__get_ticket({ticket_id}) to read the full " + f"ticket details, then complete the work and mark the ticket as completed." + ) + return ( + f"#!/usr/bin/env bash\n" + f"# Startup script for {human_id}: {title}\n" + f"cd {_REPO_PATH}\n" + f'claude "{prompt}" \\\n' + f' --allowedTools "{tools_str}"\n' + ) + + +def _client() -> httpx.Client: + return httpx.Client(base_url=HOMEDASH_URL, headers=_HEADERS, timeout=10.0) + + +def _check(r: httpx.Response) -> dict | list: + if not r.is_success: + raise RuntimeError(f"API error {r.status_code}: {r.text}") + return r.json() + + +# ── Ticket tools ────────────────────────────────────────────────────────────── + +@mcp.tool() +def list_tickets(status: str = "", type: str = "", q: str = "") -> list[dict]: + """ + List homelab tickets. Results include attachment_count so you know + which tickets have supporting documents without fetching them. + + Args: + status: Filter by status — open, ongoing, completed, abandoned. + Leave empty to list all statuses. + type: Filter by type — feature, bug, chore, project. + Leave empty to list all types. + q: Full-text search query matched against ticket title and + description (case-insensitive). Leave empty to skip. + """ + with _client() as c: + params = {} + if status: + params["status"] = status + if type: + params["type"] = type + if q: + params["q"] = q + return _check(c.get("/api/tickets", params=params)) + + +@mcp.tool() +def get_ticket(ticket_id: int) -> dict: + """ + Get a single ticket by its numeric ID, including attachment_count. + Use list_attachments to fetch the actual attachment content. + + Args: + ticket_id: The integer primary key (e.g. 1 for HOME-001). + """ + with _client() as c: + return _check(c.get(f"/api/tickets/{ticket_id}")) + + +@mcp.tool() +def create_ticket( + title: str, + description: str = "", + status: str = "open", + type: str = "feature", + user: str = "claude", + parent_id: int = 0, + effort: int = 0, +) -> dict: + """ + Create a new homelab ticket. For longer supporting documents + (proposals, reports, specs) prefer add_attachment over stuffing + everything into description. + + Args: + title: Short summary of the work item. + description: Markdown body with context, links, acceptance criteria. + status: open (default), ongoing, completed, abandoned. + type: feature (default), bug, chore, project. + user: Who is submitting — kevin or claude (default: claude). + parent_id: ID of the parent ticket (omit or pass 0 for no parent). + effort: Estimated effort rating 1–10 (omit or pass 0 to leave unset). + """ + body: dict = { + "title": title, + "description": description or None, + "status": status, + "type": type, + "user": user, + } + if parent_id: + body["parent_id"] = parent_id + if effort: + body["effort"] = effort + with _client() as c: + ticket = _check(c.post("/api/tickets", json=body)) + script = _generate_startup_script( + ticket["id"], ticket["human_id"], title, description, type + ) + ticket = _check(c.patch(f"/api/tickets/{ticket['id']}", json={"startup_script": script})) + return ticket + + +@mcp.tool() +def delete_ticket(ticket_id: int) -> dict: + """ + Permanently delete a ticket and all its attachments. + + Args: + ticket_id: The integer ID of the ticket to delete. + """ + with _client() as c: + return _check(c.delete(f"/api/tickets/{ticket_id}")) + + +@mcp.tool() +def update_ticket( + ticket_id: int, + title: str = "", + description: str = "", + status: str = "", + type: str = "", + user: str = "", + parent_id: int = -1, + effort: int = -1, +) -> dict: + """ + Update an existing homelab ticket. Only provided (non-empty) fields + are changed; omit a field to leave it unchanged. + + Args: + ticket_id: The integer ID of the ticket to update. + title: New title. + description: New markdown description. + status: open, ongoing, completed, abandoned. + type: feature, bug, chore, project. + user: kevin or claude. + parent_id: Set parent ticket ID; pass 0 to clear the parent. + Omit (default -1) to leave unchanged. + effort: Effort rating 1–10; pass 0 to clear it. + Omit (default -1) to leave unchanged. + """ + body = {} + if title: body["title"] = title + if description: body["description"] = description + if status: body["status"] = status + if type: body["type"] = type + if user: body["user"] = user + if parent_id >= 0: + body["parent_id"] = parent_id if parent_id > 0 else None + if effort >= 0: + body["effort"] = effort if effort > 0 else None + if not body: + raise ValueError("Provide at least one field to update") + with _client() as c: + ticket = _check(c.patch(f"/api/tickets/{ticket_id}", json=body)) + if title or description or type: + script = _generate_startup_script( + ticket["id"], ticket["human_id"], + ticket["title"], ticket["description"], ticket["type"], + ) + ticket = _check(c.patch(f"/api/tickets/{ticket_id}", json={"startup_script": script})) + return ticket + + +# ── Attachment tools ────────────────────────────────────────────────────────── + +@mcp.tool() +def list_attachments(ticket_id: int) -> list[dict]: + """ + List all markdown attachments on a ticket (title, created_by, + created_at, and full content). + + Args: + ticket_id: The integer ID of the parent ticket. + """ + with _client() as c: + return _check(c.get(f"/api/tickets/{ticket_id}/attachments")) + + +@mcp.tool() +def add_attachment( + ticket_id: int, + title: str, + content: str, + created_by: str = "claude", +) -> dict: + """ + Attach a markdown document to a ticket. Use this for implementation + proposals, test plans, research notes, decision records, or any + supporting document that is too long for the ticket description. + + Args: + ticket_id: The integer ID of the parent ticket. + title: Descriptive name, e.g. "Implementation Proposal v1". + content: Full markdown body of the document. + created_by: kevin or claude (default: claude). + """ + with _client() as c: + return _check(c.post(f"/api/tickets/{ticket_id}/attachments", json={ + "title": title, + "content": content, + "created_by": created_by, + })) + + +@mcp.tool() +def delete_attachment(ticket_id: int, attachment_id: int) -> dict: + """ + Permanently delete an attachment from a ticket. + + Args: + ticket_id: The integer ID of the parent ticket. + attachment_id: The integer ID of the attachment to delete. + """ + with _client() as c: + return _check(c.delete(f"/api/tickets/{ticket_id}/attachments/{attachment_id}")) + + +# ── System log tools ────────────────────────────────────────────────────────── + +@mcp.tool() +def list_logs( + level: str = "", + source: str = "", + resolved: bool = False, +) -> list[dict]: + """ + List homelab system log entries (infrastructure alerts, camera issues, disk). + + Args: + level: Filter by level — ERR, WARN, INFO. Leave empty for all. + source: Filter by source — homelab, hassgrab, disk. Leave empty for all. + resolved: Include resolved logs (default False = active only). + """ + with _client() as c: + params: dict = {"resolved": str(resolved).lower()} + if level: + params["level"] = level + if source: + params["source"] = source + return _check(c.get("/api/logs", params=params)) + + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..97bbca6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "corvid" +version = "0.1.0" +description = "Standalone ticket management service for AI agents" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.30", + "uvicorn[standard]>=0.32", + "alembic>=1.14", + "pydantic>=2.0", +] + +[project.optional-dependencies] +mcp = ["mcp[cli]>=1.0", "httpx>=0.28"] +dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "aiosqlite>=0.20"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["corvid*"]