From 37b2c21720383b5be0a5c24b550448d7d20c998f Mon Sep 17 00:00:00 2001 From: caoimhinr Date: Mon, 25 May 2026 19:23:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20corvid=20=E2=80=94=20standalo?= =?UTF-8?q?ne=20ticket=20service=20extracted=20from=20homelab-dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 87 ++++++ Dockerfile | 14 + alembic.ini | 41 +++ alembic/env.py | 33 ++ alembic/script.py.mako | 24 ++ alembic/versions/0001_tickets_schema.py | 53 ++++ corvid.egg-info/PKG-INFO | 18 ++ corvid.egg-info/SOURCES.txt | 12 + corvid.egg-info/dependency_links.txt | 1 + corvid.egg-info/requires.txt | 15 + corvid.egg-info/top_level.txt | 1 + corvid/__init__.py | 1 + corvid/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 173 bytes corvid/__pycache__/config.cpython-312.pyc | Bin 0 -> 384 bytes corvid/__pycache__/database.cpython-312.pyc | Bin 0 -> 852 bytes corvid/__pycache__/main.cpython-312.pyc | Bin 0 -> 935 bytes corvid/__pycache__/models.cpython-312.pyc | Bin 0 -> 3967 bytes corvid/__pycache__/router.cpython-312.pyc | Bin 0 -> 18103 bytes corvid/config.py | 4 + corvid/database.py | 12 + corvid/main.py | 22 ++ corvid/models.py | 53 ++++ corvid/router.py | 304 +++++++++++++++++++ docker-compose.yml | 28 ++ entrypoint.sh | 4 + mcp_server.py | 320 ++++++++++++++++++++ pyproject.toml | 25 ++ 27 files changed, 1072 insertions(+) create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0001_tickets_schema.py create mode 100644 corvid.egg-info/PKG-INFO create mode 100644 corvid.egg-info/SOURCES.txt create mode 100644 corvid.egg-info/dependency_links.txt create mode 100644 corvid.egg-info/requires.txt create mode 100644 corvid.egg-info/top_level.txt create mode 100644 corvid/__init__.py create mode 100644 corvid/__pycache__/__init__.cpython-312.pyc create mode 100644 corvid/__pycache__/config.cpython-312.pyc create mode 100644 corvid/__pycache__/database.cpython-312.pyc create mode 100644 corvid/__pycache__/main.cpython-312.pyc create mode 100644 corvid/__pycache__/models.cpython-312.pyc create mode 100644 corvid/__pycache__/router.cpython-312.pyc create mode 100644 corvid/config.py create mode 100644 corvid/database.py create mode 100644 corvid/main.py create mode 100644 corvid/models.py create mode 100644 corvid/router.py create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 mcp_server.py create mode 100644 pyproject.toml 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 0000000000000000000000000000000000000000..7f46b574751a3a48bb6d7c55c59dd7cbde9f2c52 GIT binary patch literal 173 zcmX@j%ge<81YRv7SzVFyOXXNLm>L(}WXXa*P<`wA&6y;~7CYKcJC+8QHWv0NW`1s7c t%#!$cy@JYL95%W6DWy57c13JJBS7{RgPiq&nURt42A^mncM%Ja3jjP(D@6bR literal 0 HcmV?d00001 diff --git a/corvid/__pycache__/config.cpython-312.pyc b/corvid/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ded83f1731537424de13c2d54987e4b68e79557f GIT binary patch literal 384 zcmX@j%ge<81W_#_SxbQQV-N=hSfGs05kSUth7^Vr#vFzy1}277hAbwSA_Senl)}6k zDjvmzQx!`JD_j+GC8H+WOOO$Mw|HC}LmZtPgI(i8gM6yY3-XIg(u-1y3v;v+i!1Yz z3(~Fh^^^09$}&@|Ad~}4iIu5|u@O}67JrDRv$tzVaJ*xHXS}y-tu6-AeYj2iiiIDuLK)=yu$ literal 0 HcmV?d00001 diff --git a/corvid/__pycache__/database.cpython-312.pyc b/corvid/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0eb73c036d4ca89a9c4fb246b5dec44548cf7196 GIT binary patch literal 852 zcmY*XO=uHA6n?Xl-A$ULv6==E4@E%;NTMQCK?*6_i=~LI&@SS#-5t}d`x9r^wn2qr zix)*uuob-Yq)1!wq@KjP7sVcQQA9+rw@MBT#e;7)jX$&OH{bWo+qZAt%$sbs18`{b z`N<^$@J)#JNNkhF1tg1Lfkhl}iAG#WlU%A%0+wV^C*jJP9P@;uxT>ZSkbqGpTk?vs zEVZ&|N%SO^wA7X4A1`I4=0WSIcNEj>BCJTGOeLs$<}eEa+xMcKhERIYlCE)y@o1OH znGrI*wZy!t?Xh*N9f~B%oUDvg`YXey^mAtiQ78J1fQ=x@)_mX5Yn|}MvP!2(kqlw^ zUH|rJ&^HYSO?A6bfhe_S>qvRRcUjpqeA}I{JzhS;{YhqqLD}^Alx?*q%Lt8eY*eXM zkCZA4b!(jGFoU=nK|jI|XwJf?T+jR5zD*!^r6Q@%$??n!70D|Vt)XH%Dg>7u z!!akATQ9MhuoR!Z?Q?8CaB)+)j1VcN?_IR3T%2E|xW2`lfG1YG(Q<_|1Qy_VPV P18F)w@AH+LZU~! zh)1plh*$pxyq1K zVCfm?eF3n87-HB%KDIHISn(8}*aR?Ps;Byzt)*D=bl-b<{qe(3u@`!t{{TtL{ErBVENaqdiB0sipv-1Pj+@=^_AwfUK6 z5Ykt8Tw`@Wd7rNGng}~F7YU*5wv!1<#mKe@nBGkQ^B`6}AU(Dj>cAgYTZY;h-!s!k}@TsGKz>IPHoZen_FgGX%|hyvux zfkv|0FZ5>vovPN~NCoe;VS(=Hv_M5kwAhV3o+f~2(6(D!nMs*nN|BI){sF2`)-?bC literal 0 HcmV?d00001 diff --git a/corvid/__pycache__/models.cpython-312.pyc b/corvid/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f272635ca44b6f661f68e8faa2e834748a7890d GIT binary patch literal 3967 zcmb_fU2GHC6`rxjTqC{SX> zHM^J9e6FbJR*cacfbzOAn=Po0?GEMLkBt}%o^X`Q@Py0wz*L_lpvsiww?eY4D;kw$ zD+yLx4Z*ncDR=8bY&8xlXO*g)iFD% zNIG52Zp4(jr5>woDy!b6<{8se)LD({rqyBE99gNejHw0QiWXQ_Q&>s94PoialD82D zXAEVD6+C+;efG?`^MDKGb?`4WX%av5VlJml=cpBSwPwj{4t!(<%&ZCQpqhsO*Ui_b z!5}Meo8Hap3qJM^egz4gQHnX!il~gj=rpk5z&k50;FnSvr%PHD4Q9dm1 z-hPxr-V_22l&f&Ry8r73&mR2l;_oMZJF$A=O2ttH+mc{aZ-*{zwUYs``M^}aM^}eIex?fBzr?1zJ-B_O0YJGa;BX3s6+0GaK=RHfs zTHj>lyf-lk6Q3uR0=2$m<$}jdI%wbX?j^c>^?I%EM&)B~?gq~7S{kh#ySP4lWz*01 zr+It!{z_Z#2FC}|{5K-kd-#_Zmu}Sdrz^=v$@!Bju{|{*?YJGcqr$J=g6?SJM-XuT zOJ?%IkNAj4ptMv&i*C5uxhd-2-f%-!2$M)%N+i*0te!_OJ9!d~nJ3(kdBkdKJwL=7 z@NM1-P{h29ikU1*+RLz8*NzSk1^KKEveOO^OA=uF&bsDyknC_}IvZrdq^m9=l3fk9 z?`W{a&QxR`+?ffR$T2?gW`&(~kCglC-M{x$8fWy>(^FR4X6N3lu=LV{5%#0>-?KbBInD>pnx1i^oG{=qr zTk^A0&{;yU2q`~+baeZDKL&tutCP9v>2E(U4a`dw?&Zi!^ULxV<=;G5JvC8rk{>)X z#G;1p@cQT=H3)HiNAjAfd90CF>8|8d$+rMu#f;*#mNl``Ddua; z=9Z8zP~8#@$k0W@3eG?sEHY{dW~o5g9zc{s>GM;)sjCwcY#$1Q3krjJDuahaIx~}J zrqu${%q$jU=aihBjLEv(q2zLhd!2H9B+m-7ieBzleDA3-uPwLsFNgZ=q%Q5>Rz2BCz;COW?Ayru0Et~4&*BKL1UrWWz3kM<^Ek3g z20MI^MIWUx?y1a z7)}C9>s~0WgQH7{+F%k&Yx3#H<4dbTa(!_0*$-ZfKhL}pmako3ADMu%cPPbI&N&FI zdMO^{T+qPsr#EYZiZ{I~D2{@&&wu#pcx~_sR9{!~m38U;&&^e7ta1rizexP_gO$X= zRbdPTK3Otr$0ycDuHtIf_{s!8POc>Sak*>F%gMFDo0X)e_2!nnV9IoDP~9>`g%zKb zmkuv~s@6`B<-AdoOn4?VE^eYx_g*DxQp(X;H!EeEyF_ZzrAn$v=F-=rr`JN=YhC-- z+WObpht}da@MDlG3EUfFeYrTy3jCkDhfZJgNh#>WQ+p7#9{_A;>H2gWzZ za9#-L8mXO~Mi1G}7hq&Xj5|3cr_NHXh|H6%b#a&+IE^mrF iAAa=RO%A%vP?VqI=gmzH`^`N){J^8Be{s;++xj=={G8(e literal 0 HcmV?d00001 diff --git a/corvid/__pycache__/router.cpython-312.pyc b/corvid/__pycache__/router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe11695c51b95b2964019b41edf53dc66fa2ea1e GIT binary patch literal 18103 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 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*"]