diff --git a/src/McpContext.ts b/src/McpContext.ts index 11bb3d971..da939a30d 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -344,20 +344,28 @@ export class McpContext implements Context { return this.#selectedPage === page; } + #maybeEmulateFocusedPage(page: Page, focused: boolean): void { + const maybeEmulate = ( + page as Page & { emulateFocusedPage?: (focused: boolean) => Promise } + ).emulateFocusedPage; + if (!maybeEmulate) { + return; + } + void maybeEmulate.call(page, focused).catch(error => { + this.logger('Error toggling focused page emulation', error); + }); + } + selectPage(newPage: Page): void { const oldPage = this.#selectedPage; if (oldPage) { oldPage.off('dialog', this.#dialogHandler); - void oldPage.emulateFocusedPage(false).catch(error => { - this.logger('Error turning off focused page emulation', error); - }); + this.#maybeEmulateFocusedPage(oldPage, false); } this.#selectedPage = newPage; newPage.on('dialog', this.#dialogHandler); this.#updateSelectedPageTimeouts(); - void newPage.emulateFocusedPage(true).catch(error => { - this.logger('Error turning on focused page emulation', error); - }); + this.#maybeEmulateFocusedPage(newPage, true); } #updateSelectedPageTimeouts() { diff --git a/zacino/.env.example b/zacino/.env.example new file mode 100644 index 000000000..c0cc43d36 --- /dev/null +++ b/zacino/.env.example @@ -0,0 +1,11 @@ +POSTGRES_DB=zacino +POSTGRES_USER=zacino +POSTGRES_PASSWORD=change-me +DATABASE_URL=postgresql+asyncpg://zacino:change-me@db:5432/zacino +SECRET_KEY=replace-with-strong-secret +ACCESS_TOKEN_EXPIRE_MINUTES=60 +CORS_ORIGINS=http://localhost:5173,http://localhost:4173 +AUTO_CREATE_DB=true +VITE_API_URL=http://localhost:8000 +BACKEND_PORT=8000 +FRONTEND_PORT=5173 diff --git a/zacino/.gitea/workflows/ci.yml b/zacino/.gitea/workflows/ci.yml new file mode 100644 index 000000000..87dcb0752 --- /dev/null +++ b/zacino/.gitea/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install backend dependencies + working-directory: zacino/backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Lint backend + working-directory: zacino/backend + run: ruff check app tests + + - name: Test backend + working-directory: zacino/backend + env: + SECRET_KEY: test-secret + run: pytest + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install frontend dependencies + working-directory: zacino/frontend + run: npm ci + + - name: Lint frontend + working-directory: zacino/frontend + run: npm run lint + + - name: Test frontend + working-directory: zacino/frontend + run: npm test + + - name: Build frontend + working-directory: zacino/frontend + run: npm run build + + - name: Build Docker images + working-directory: zacino + run: docker compose build + + - name: Deploy (compose up) + working-directory: zacino + env: + SECRET_KEY: ${{ secrets.SECRET_KEY }} + run: | + if [ -n "$SECRET_KEY" ]; then + cp .env.example .env + sed -i "s|replace-with-strong-secret|$SECRET_KEY|" .env + docker compose up -d + else + echo "Skipping deploy: SECRET_KEY not set" + fi + + - name: Notify + if: always() + env: + WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + run: | + if [ -n "$WEBHOOK_URL" ]; then + curl -X POST -H 'Content-Type: application/json' \ + -d '{"status":"'"$GITHUB_JOB"'","result":"'"${{ job.status }}"'"}' \ + "$WEBHOOK_URL" + fi diff --git a/zacino/.gitignore b/zacino/.gitignore new file mode 100644 index 000000000..aea4f4464 --- /dev/null +++ b/zacino/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ + +# Node +node_modules/ +/dist/ + +# Env +.env + +# Logs +*.log + +# OS +.DS_Store diff --git a/zacino/LICENSE b/zacino/LICENSE new file mode 100644 index 000000000..876059a87 --- /dev/null +++ b/zacino/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 zacino + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/zacino/README.md b/zacino/README.md new file mode 100644 index 000000000..9086776c1 --- /dev/null +++ b/zacino/README.md @@ -0,0 +1,95 @@ +# zacino + +**zacino** is a Meta full-stack, high-performance optimization command center for enterprise-grade teams. It provides the **Meta Core Feature**: a secure, JWT-based optimization job queue with real-time lifecycle controls and scoring. + +## Tech stack + +- **Backend:** FastAPI + SQLAlchemy (async) + Uvicorn +- **Frontend:** React + Vite + TypeScript +- **Database:** PostgreSQL (Docker) / SQLite (local fallback) +- **Auth:** JWT-based +- **Testing:** Pytest + Vitest +- **CI/CD:** Gitea Actions +- **Deploy:** Docker Compose with `.env` configuration + +## Project structure + +``` +backend/ FastAPI service +frontend/ React UI +``` + +## Local setup + +1. Copy environment variables: + +```bash +cp .env.example .env +``` + +2. Backend (Python 3.12): + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt -r requirements-dev.txt +uvicorn app.main:app --reload +``` + +3. Frontend (Node 20+): + +```bash +cd frontend +npm install +npm run dev +``` + +The frontend runs on `http://localhost:5173` and connects to the API on `http://localhost:8000`. + +## Docker setup + +```bash +cp .env.example .env + +docker compose up --build +``` + +- Frontend: `http://localhost:5173` +- Backend: `http://localhost:8000` + +## Tests + +Backend: + +```bash +cd backend +pytest +``` + +Frontend: + +```bash +cd frontend +npm test +``` + +## API overview + +- `POST /api/v1/auth/register` - register user +- `POST /api/v1/auth/login` - login and receive JWT +- `GET /api/v1/jobs` - list jobs +- `POST /api/v1/jobs` - create job +- `PATCH /api/v1/jobs/{id}` - update job +- `DELETE /api/v1/jobs/{id}` - delete job +- `GET /healthz` - liveness +- `GET /readyz` - readiness (DB check) + +## Security notes + +- Set a strong `SECRET_KEY` in `.env`. +- Configure `CORS_ORIGINS` with approved domains. + +## License + +MIT License. See [LICENSE](./LICENSE). diff --git a/zacino/backend/Dockerfile b/zacino/backend/Dockerfile new file mode 100644 index 000000000..e6ec9787b --- /dev/null +++ b/zacino/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/zacino/backend/app/__init__.py b/zacino/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zacino/backend/app/api/__init__.py b/zacino/backend/app/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zacino/backend/app/api/deps.py b/zacino/backend/app/api/deps.py new file mode 100644 index 000000000..9312663e1 --- /dev/null +++ b/zacino/backend/app/api/deps.py @@ -0,0 +1,56 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.security import verify_password +from app.db.models import User +from app.db.session import get_session + +settings = get_settings() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api_v1_prefix}/auth/login") + + +async def get_db() -> AsyncSession: + async for session in get_session(): + yield session + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + session: AsyncSession = Depends(get_db), +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + subject: str | None = payload.get("sub") + except JWTError as exc: + raise credentials_exception from exc + + if not subject: + raise credentials_exception + + result = await session.execute(select(User).where(User.email == subject)) + user = result.scalar_one_or_none() + if not user: + raise credentials_exception + if not user.is_active: + raise HTTPException(status_code=403, detail="Inactive user") + return user + + +async def authenticate_user(session: AsyncSession, email: str, password: str) -> User | None: + result = await session.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user diff --git a/zacino/backend/app/api/routes_auth.py b/zacino/backend/app/api/routes_auth.py new file mode 100644 index 000000000..9aa527126 --- /dev/null +++ b/zacino/backend/app/api/routes_auth.py @@ -0,0 +1,55 @@ +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import authenticate_user, get_db +from app.core.config import get_settings +from app.core.security import create_access_token, get_password_hash +from app.db.models import User + +router = APIRouter(prefix="/auth", tags=["auth"]) +settings = get_settings() + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + + +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register(payload: RegisterRequest, session: AsyncSession = Depends(get_db)) -> TokenResponse: + result = await session.execute(select(User).where(User.email == payload.email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + user = User(email=payload.email, hashed_password=get_password_hash(payload.password)) + session.add(user) + await session.commit() + + access_token = create_access_token(subject=user.email) + return TokenResponse(access_token=access_token) + + +@router.post("/login", response_model=TokenResponse) +async def login(payload: LoginRequest, session: AsyncSession = Depends(get_db)) -> TokenResponse: + user = await authenticate_user(session, payload.email, payload.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + access_token = create_access_token( + subject=user.email, expires_delta=timedelta(minutes=settings.access_token_expire_minutes) + ) + return TokenResponse(access_token=access_token) diff --git a/zacino/backend/app/api/routes_health.py b/zacino/backend/app/api/routes_health.py new file mode 100644 index 000000000..fd2de145d --- /dev/null +++ b/zacino/backend/app/api/routes_health.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db + +router = APIRouter(tags=["health"]) + + +@router.get("/healthz") +async def healthz() -> dict[str, str]: + return {"status": "ok"} + + +@router.get("/readyz") +async def readyz(session: AsyncSession = Depends(get_db)) -> dict[str, str]: + await session.execute(text("SELECT 1")) + return {"status": "ready"} diff --git a/zacino/backend/app/api/routes_jobs.py b/zacino/backend/app/api/routes_jobs.py new file mode 100644 index 000000000..dd172913b --- /dev/null +++ b/zacino/backend/app/api/routes_jobs.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, get_db +from app.db.models import Job, JobStatus, User + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + + +class JobCreate(BaseModel): + name: str = Field(min_length=3, max_length=120) + description: str | None = Field(default=None, max_length=1000) + + +class JobUpdate(BaseModel): + name: str | None = Field(default=None, min_length=3, max_length=120) + description: str | None = Field(default=None, max_length=1000) + status: JobStatus | None = None + score: float | None = Field(default=None, ge=0.0, le=100.0) + + +class JobResponse(BaseModel): + id: str + name: str + description: str | None + status: JobStatus + score: float + + class Config: + from_attributes = True + + +@router.get("", response_model=list[JobResponse]) +async def list_jobs( + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[JobResponse]: + result = await session.execute(select(Job).where(Job.owner_id == current_user.id)) + return list(result.scalars().all()) + + +@router.post("", response_model=JobResponse, status_code=status.HTTP_201_CREATED) +async def create_job( + payload: JobCreate, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> JobResponse: + job = Job( + name=payload.name, + description=payload.description, + owner_id=current_user.id, + score=0.0, + ) + session.add(job) + await session.commit() + await session.refresh(job) + return job + + +@router.patch("/{job_id}", response_model=JobResponse) +async def update_job( + job_id: str, + payload: JobUpdate, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> JobResponse: + result = await session.execute( + select(Job).where(Job.id == job_id, Job.owner_id == current_user.id) + ) + job = result.scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + updates = payload.model_dump(exclude_unset=True) + for field, value in updates.items(): + setattr(job, field, value) + await session.commit() + await session.refresh(job) + return job + + +@router.delete("/{job_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_job( + job_id: str, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + result = await session.execute( + select(Job).where(Job.id == job_id, Job.owner_id == current_user.id) + ) + job = result.scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="Job not found") + await session.delete(job) + await session.commit() diff --git a/zacino/backend/app/core/__init__.py b/zacino/backend/app/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zacino/backend/app/core/config.py b/zacino/backend/app/core/config.py new file mode 100644 index 000000000..6b28c6f12 --- /dev/null +++ b/zacino/backend/app/core/config.py @@ -0,0 +1,25 @@ +from functools import lru_cache +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_ignore_empty=True) + + app_name: str = "zacino" + api_v1_prefix: str = "/api/v1" + secret_key: str = "" + algorithm: str = "HS256" + access_token_expire_minutes: int = 60 + database_url: str = "sqlite+aiosqlite:///./zacino.db" + cors_origins: str = "" + auto_create_db: bool = True + + def cors_origin_list(self) -> list[str]: + if not self.cors_origins: + return [] + return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/zacino/backend/app/core/security.py b/zacino/backend/app/core/security.py new file mode 100644 index 000000000..28e0e96ee --- /dev/null +++ b/zacino/backend/app/core/security.py @@ -0,0 +1,26 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import get_settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str: + settings = get_settings() + if expires_delta is None: + expires_delta = timedelta(minutes=settings.access_token_expire_minutes) + expire = datetime.now(tz=timezone.utc) + expires_delta + to_encode: dict[str, Any] = {"exp": expire, "sub": subject} + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) diff --git a/zacino/backend/app/db/__init__.py b/zacino/backend/app/db/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zacino/backend/app/db/init_db.py b/zacino/backend/app/db/init_db.py new file mode 100644 index 000000000..b87f29c99 --- /dev/null +++ b/zacino/backend/app/db/init_db.py @@ -0,0 +1,7 @@ +from app.db.models import Base +from app.db.session import engine + + +async def init_db() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/zacino/backend/app/db/models.py b/zacino/backend/app/db/models.py new file mode 100644 index 000000000..34290ee76 --- /dev/null +++ b/zacino/backend/app/db/models.py @@ -0,0 +1,46 @@ +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, Column, DateTime, Enum, Float, ForeignKey, String, Text +from sqlalchemy.orm import declarative_base, relationship + +Base = declarative_base() + + +def utc_now() -> datetime: + return datetime.now(tz=timezone.utc) + + +class JobStatus(str, enum.Enum): + queued = "queued" + running = "running" + completed = "completed" + failed = "failed" + + +class User(Base): + __tablename__ = "users" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + email = Column(String(255), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), default=utc_now) + + jobs = relationship("Job", back_populates="owner", cascade="all, delete-orphan") + + +class Job(Base): + __tablename__ = "jobs" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String(120), nullable=False) + description = Column(Text, nullable=True) + status = Column(Enum(JobStatus), default=JobStatus.queued) + score = Column(Float, default=0.0) + owner_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), default=utc_now) + updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) + + owner = relationship("User", back_populates="jobs") diff --git a/zacino/backend/app/db/session.py b/zacino/backend/app/db/session.py new file mode 100644 index 000000000..055fefb5a --- /dev/null +++ b/zacino/backend/app/db/session.py @@ -0,0 +1,13 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import get_settings + +settings = get_settings() + +engine = create_async_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_session() -> AsyncSession: + async with SessionLocal() as session: + yield session diff --git a/zacino/backend/app/main.py b/zacino/backend/app/main.py new file mode 100644 index 000000000..23ebb99a8 --- /dev/null +++ b/zacino/backend/app/main.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes_auth import router as auth_router +from app.api.routes_health import router as health_router +from app.api.routes_jobs import router as jobs_router +from app.core.config import get_settings +from app.db.init_db import init_db + +settings = get_settings() + +app = FastAPI(title=settings.app_name) + +if settings.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list(), + allow_credentials=True, + allow_methods=["*"] , + allow_headers=["*"] , + ) + + +@app.on_event("startup") +async def on_startup() -> None: + if settings.auto_create_db: + await init_db() + + +app.include_router(health_router) +app.include_router(auth_router, prefix=settings.api_v1_prefix) +app.include_router(jobs_router, prefix=settings.api_v1_prefix) diff --git a/zacino/backend/pyproject.toml b/zacino/backend/pyproject.toml new file mode 100644 index 000000000..90771dbdc --- /dev/null +++ b/zacino/backend/pyproject.toml @@ -0,0 +1,12 @@ +[tool.ruff] +line-length = 100 +select = ["E", "F", "I", "B", "UP", "ASYNC"] +ignore = ["E501"] + +[tool.ruff.isort] +known-first-party = ["app"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +addopts = "-q" + diff --git a/zacino/backend/requirements-dev.txt b/zacino/backend/requirements-dev.txt new file mode 100644 index 000000000..74c34e779 --- /dev/null +++ b/zacino/backend/requirements-dev.txt @@ -0,0 +1,6 @@ +pytest==8.3.3 +pytest-asyncio==0.24.0 +httpx==0.27.2 +ruff==0.6.9 +pytest-cov==5.0.0 +aiosqlite==0.20.0 diff --git a/zacino/backend/requirements.txt b/zacino/backend/requirements.txt new file mode 100644 index 000000000..649262e1d --- /dev/null +++ b/zacino/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.2 +uvicorn[standard]==0.30.6 +SQLAlchemy==2.0.36 +asyncpg==0.29.0 +aiosqlite==0.20.0 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-jose==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 diff --git a/zacino/backend/tests/conftest.py b/zacino/backend/tests/conftest.py new file mode 100644 index 000000000..15fa58886 --- /dev/null +++ b/zacino/backend/tests/conftest.py @@ -0,0 +1,48 @@ +import asyncio + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_db +from app.db.models import Base +from app.main import app + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session") +async def test_engine(): + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture() +async def db_session(test_engine): + session_factory = async_sessionmaker(bind=test_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + yield session + + +@pytest.fixture() +async def client(db_session): + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + async with AsyncClient(app=app, base_url="http://test") as async_client: + yield async_client + app.dependency_overrides.clear() diff --git a/zacino/backend/tests/test_auth.py b/zacino/backend/tests/test_auth.py new file mode 100644 index 000000000..1528b86b1 --- /dev/null +++ b/zacino/backend/tests/test_auth.py @@ -0,0 +1,15 @@ +import pytest + + +@pytest.mark.asyncio +async def test_register_and_login(client): + register_payload = {"email": "admin@zacino.io", "password": "SecurePass123"} + register_resp = await client.post("/api/v1/auth/register", json=register_payload) + assert register_resp.status_code == 201 + token = register_resp.json()["access_token"] + assert token + + login_resp = await client.post("/api/v1/auth/login", json=register_payload) + assert login_resp.status_code == 200 + login_token = login_resp.json()["access_token"] + assert login_token diff --git a/zacino/backend/tests/test_jobs.py b/zacino/backend/tests/test_jobs.py new file mode 100644 index 000000000..d2eacf616 --- /dev/null +++ b/zacino/backend/tests/test_jobs.py @@ -0,0 +1,26 @@ +import pytest + + +@pytest.mark.asyncio +async def test_job_lifecycle(client): + register_payload = {"email": "ops@zacino.io", "password": "SecurePass123"} + register_resp = await client.post("/api/v1/auth/register", json=register_payload) + token = register_resp.json()["access_token"] + + headers = {"Authorization": f"Bearer {token}"} + job_payload = {"name": "Optimize supply chain", "description": "Reduce latency."} + create_resp = await client.post("/api/v1/jobs", json=job_payload, headers=headers) + assert create_resp.status_code == 201 + job_id = create_resp.json()["id"] + + list_resp = await client.get("/api/v1/jobs", headers=headers) + assert list_resp.status_code == 200 + assert len(list_resp.json()) == 1 + + update_payload = {"status": "running", "score": 72.5} + update_resp = await client.patch(f"/api/v1/jobs/{job_id}", json=update_payload, headers=headers) + assert update_resp.status_code == 200 + assert update_resp.json()["status"] == "running" + + delete_resp = await client.delete(f"/api/v1/jobs/{job_id}", headers=headers) + assert delete_resp.status_code == 204 diff --git a/zacino/docker-compose.yml b/zacino/docker-compose.yml new file mode 100644 index 000000000..4b92ab1b5 --- /dev/null +++ b/zacino/docker-compose.yml @@ -0,0 +1,46 @@ +version: "3.9" + +services: + db: + image: postgres:16.4 + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - db_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + env_file: .env + environment: + DATABASE_URL: ${DATABASE_URL} + SECRET_KEY: ${SECRET_KEY} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES} + CORS_ORIGINS: ${CORS_ORIGINS} + AUTO_CREATE_DB: ${AUTO_CREATE_DB} + depends_on: + db: + condition: service_healthy + ports: + - "${BACKEND_PORT}:8000" + + frontend: + build: ./frontend + env_file: .env + environment: + VITE_API_URL: ${VITE_API_URL} + depends_on: + - backend + ports: + - "${FRONTEND_PORT}:80" + +volumes: + db_data: diff --git a/zacino/frontend/Dockerfile b/zacino/frontend/Dockerfile new file mode 100644 index 000000000..ee7991344 --- /dev/null +++ b/zacino/frontend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20.18-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} +RUN npm run build + +FROM nginx:1.27-alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/zacino/frontend/eslint.config.js b/zacino/frontend/eslint.config.js new file mode 100644 index 000000000..5c370ef7b --- /dev/null +++ b/zacino/frontend/eslint.config.js @@ -0,0 +1,36 @@ +import js from "@eslint/js"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import parser from "@typescript-eslint/parser"; + +export default [ + js.configs.recommended, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parser, + parserOptions: { + ecmaFeatures: { jsx: true }, + sourceType: "module" + } + }, + plugins: { + "@typescript-eslint": tseslint, + react, + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + settings: { + react: { version: "detect" } + }, + rules: { + "react/react-in-jsx-scope": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }] + } + } +]; diff --git a/zacino/frontend/index.html b/zacino/frontend/index.html new file mode 100644 index 000000000..90cd032c7 --- /dev/null +++ b/zacino/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + zacino • Meta Optimization Command Center + + +
+ + + diff --git a/zacino/frontend/nginx.conf b/zacino/frontend/nginx.conf new file mode 100644 index 000000000..bb3f60868 --- /dev/null +++ b/zacino/frontend/nginx.conf @@ -0,0 +1,11 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } +} diff --git a/zacino/frontend/package-lock.json b/zacino/frontend/package-lock.json new file mode 100644 index 000000000..f09c90f44 --- /dev/null +++ b/zacino/frontend/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "zacino-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zacino-frontend", + "version": "1.0.0", + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.0.1", + "@testing-library/user-event": "14.5.2", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@typescript-eslint/eslint-plugin": "8.10.0", + "@typescript-eslint/parser": "8.10.0", + "@vitejs/plugin-react": "4.3.2", + "eslint": "9.13.0", + "eslint-plugin-react": "7.37.1", + "eslint-plugin-react-hooks": "5.1.0-rc.0", + "eslint-plugin-react-refresh": "0.4.13", + "jsdom": "25.0.1", + "typescript": "5.6.3", + "vite": "5.4.9", + "vitest": "2.1.4" + } + } + } +} diff --git a/zacino/frontend/package.json b/zacino/frontend/package.json new file mode 100644 index 000000000..fcc2a28fe --- /dev/null +++ b/zacino/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "zacino-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0 --port 4173", + "test": "vitest run", + "lint": "eslint . --max-warnings=0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.0.1", + "@testing-library/user-event": "14.5.2", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "@typescript-eslint/eslint-plugin": "8.10.0", + "@typescript-eslint/parser": "8.10.0", + "@vitejs/plugin-react": "4.3.2", + "eslint": "9.13.0", + "eslint-plugin-react": "7.37.1", + "eslint-plugin-react-hooks": "5.1.0-rc.0", + "eslint-plugin-react-refresh": "0.4.13", + "jsdom": "25.0.1", + "typescript": "5.6.3", + "vite": "5.4.9", + "vitest": "2.1.4" + } +} diff --git a/zacino/frontend/src/App.tsx b/zacino/frontend/src/App.tsx new file mode 100644 index 000000000..5af5b0ca8 --- /dev/null +++ b/zacino/frontend/src/App.tsx @@ -0,0 +1,111 @@ +import { useEffect, useMemo, useState } from "react"; +import { api, Job } from "./api"; +import { clearToken, getToken, setToken } from "./auth"; +import DashboardPage from "./pages/DashboardPage"; +import AuthPage from "./pages/AuthPage"; + +export type AuthState = { + token: string | null; + error: string | null; + loading: boolean; +}; + +function App() { + const [authState, setAuthState] = useState({ + token: getToken(), + error: null, + loading: false + }); + const [jobs, setJobs] = useState([]); + const [notice, setNotice] = useState(null); + + const isAuthenticated = useMemo(() => Boolean(authState.token), [authState.token]); + + useEffect(() => { + if (!isAuthenticated) { + setJobs([]); + return; + } + api + .listJobs() + .then(setJobs) + .catch((error: Error) => setNotice(error.message)); + }, [isAuthenticated]); + + const handleAuth = async (mode: "login" | "register", email: string, password: string) => { + setAuthState((prev) => ({ ...prev, loading: true, error: null })); + try { + const response = + mode === "login" ? await api.login(email, password) : await api.register(email, password); + setToken(response.access_token); + setAuthState({ token: response.access_token, error: null, loading: false }); + setNotice("Authenticated. Loading optimization jobs..."); + } catch (error) { + setAuthState((prev) => ({ + ...prev, + loading: false, + error: (error as Error).message + })); + } + }; + + const handleLogout = () => { + clearToken(); + setAuthState({ token: null, error: null, loading: false }); + }; + + const handleCreateJob = async (name: string, description: string) => { + try { + const job = await api.createJob({ name, description }); + setJobs((prev) => [job, ...prev]); + setNotice("Optimization job queued."); + } catch (error) { + setNotice((error as Error).message); + } + }; + + const handleUpdateJob = async (jobId: string, status: Job["status"], score: number) => { + try { + const job = await api.updateJob(jobId, { status, score }); + setJobs((prev) => prev.map((item) => (item.id === job.id ? job : item))); + setNotice("Job status updated."); + } catch (error) { + setNotice((error as Error).message); + } + }; + + const handleDeleteJob = async (jobId: string) => { + try { + await api.deleteJob(jobId); + setJobs((prev) => prev.filter((item) => item.id !== jobId)); + setNotice("Job removed."); + } catch (error) { + setNotice((error as Error).message); + } + }; + + return ( +
+ +
+ {notice &&
{notice}
} + {isAuthenticated ? ( + + ) : ( + + )} +
+
+ ); +} + +export default App; diff --git a/zacino/frontend/src/__tests__/App.test.tsx b/zacino/frontend/src/__tests__/App.test.tsx new file mode 100644 index 000000000..209f6303c --- /dev/null +++ b/zacino/frontend/src/__tests__/App.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import App from "../App"; + +const mockFetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ access_token: "token", token_type: "bearer" }) + }) +); + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + localStorage.clear(); + mockFetch.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("renders authentication form and validates input", async () => { + render(); + + const button = screen.getByRole("button", { name: /sign in/i }); + expect(button).toBeDisabled(); + + await userEvent.type(screen.getByLabelText(/email/i), "team@zacino.io"); + await userEvent.type(screen.getByLabelText(/password/i), "StrongPass123"); + + expect(button).toBeEnabled(); +}); diff --git a/zacino/frontend/src/api.ts b/zacino/frontend/src/api.ts new file mode 100644 index 000000000..fedf253ae --- /dev/null +++ b/zacino/frontend/src/api.ts @@ -0,0 +1,66 @@ +import { getToken } from "./auth"; + +const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + +export type Job = { + id: string; + name: string; + description: string | null; + status: "queued" | "running" | "completed" | "failed"; + score: number; +}; + +export type AuthResponse = { + access_token: string; + token_type: string; +}; + +async function apiFetch(path: string, options: RequestInit = {}): Promise { + const token = getToken(); + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.headers ?? {}) + } + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({ detail: "Request failed" })); + throw new Error(errorBody.detail ?? "Request failed"); + } + + if (response.status === 204) { + return null as T; + } + return (await response.json()) as T; +} + +export const api = { + register: (email: string, password: string) => + apiFetch("/api/v1/auth/register", { + method: "POST", + body: JSON.stringify({ email, password }) + }), + login: (email: string, password: string) => + apiFetch("/api/v1/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }) + }), + listJobs: () => apiFetch("/api/v1/jobs"), + createJob: (payload: { name: string; description?: string }) => + apiFetch("/api/v1/jobs", { + method: "POST", + body: JSON.stringify(payload) + }), + updateJob: (jobId: string, payload: Partial) => + apiFetch(`/api/v1/jobs/${jobId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }), + deleteJob: (jobId: string) => + apiFetch(`/api/v1/jobs/${jobId}`, { + method: "DELETE" + }) +}; diff --git a/zacino/frontend/src/auth.ts b/zacino/frontend/src/auth.ts new file mode 100644 index 000000000..a06d384a7 --- /dev/null +++ b/zacino/frontend/src/auth.ts @@ -0,0 +1,13 @@ +const TOKEN_KEY = "zacino.token"; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY); +} diff --git a/zacino/frontend/src/index.css b/zacino/frontend/src/index.css new file mode 100644 index 000000000..7b6fdc3a1 --- /dev/null +++ b/zacino/frontend/src/index.css @@ -0,0 +1,117 @@ +:root { + color-scheme: light; + font-family: "Inter", system-ui, sans-serif; + line-height: 1.5; + font-weight: 400; + color: #0f172a; + background-color: #f8fafc; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: #f8fafc; +} + +.app { + display: grid; + grid-template-columns: 260px 1fr; + min-height: 100vh; +} + +.sidebar { + background: #0f172a; + color: #e2e8f0; + padding: 32px 24px; +} + +.sidebar h1 { + font-size: 1.5rem; + margin-bottom: 12px; +} + +.sidebar p { + font-size: 0.95rem; + color: #cbd5f5; +} + +.content { + padding: 32px 40px; +} + +.card { + background: #ffffff; + border-radius: 16px; + padding: 24px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + margin-bottom: 24px; +} + +.form-grid { + display: grid; + gap: 16px; +} + +input, select, textarea { + width: 100%; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid #cbd5f5; + font-size: 0.95rem; +} + +button { + padding: 12px 20px; + border-radius: 10px; + border: none; + background: #2563eb; + color: #fff; + font-weight: 600; + cursor: pointer; +} + +button.secondary { + background: #e2e8f0; + color: #0f172a; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + background: #e2e8f0; + color: #0f172a; +} + +.job-list { + display: grid; + gap: 16px; +} + +.job-row { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.job-meta { + display: flex; + gap: 12px; + align-items: center; +} + +.notice { + padding: 12px 14px; + border-radius: 10px; + background: #ecfeff; + color: #0e7490; + font-size: 0.9rem; +} diff --git a/zacino/frontend/src/main.tsx b/zacino/frontend/src/main.tsx new file mode 100644 index 000000000..9b67590a0 --- /dev/null +++ b/zacino/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/zacino/frontend/src/pages/AuthPage.tsx b/zacino/frontend/src/pages/AuthPage.tsx new file mode 100644 index 000000000..4777282a6 --- /dev/null +++ b/zacino/frontend/src/pages/AuthPage.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; + +type AuthPageProps = { + onAuth: (mode: "login" | "register", email: string, password: string) => void; + loading: boolean; + error: string | null; +}; + +const initialForm = { email: "", password: "" }; + +function AuthPage({ onAuth, loading, error }: AuthPageProps) { + const [mode, setMode] = useState<"login" | "register">("login"); + const [form, setForm] = useState(initialForm); + + const canSubmit = form.email.length > 3 && form.password.length >= 8; + + return ( +
+

{mode === "login" ? "Sign in" : "Create enterprise account"}

+

Secure access with JWT-based identity controls.

+ {error &&

{error}

} +
+ + + + +
+
+ ); +} + +export default AuthPage; diff --git a/zacino/frontend/src/pages/DashboardPage.tsx b/zacino/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 000000000..a21b019bc --- /dev/null +++ b/zacino/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { Job } from "../api"; + +const statusOptions: Job["status"][] = ["queued", "running", "completed", "failed"]; + +type DashboardPageProps = { + jobs: Job[]; + onCreateJob: (name: string, description: string) => void; + onUpdateJob: (jobId: string, status: Job["status"], score: number) => void; + onDeleteJob: (jobId: string) => void; + onLogout: () => void; +}; + +function DashboardPage({ jobs, onCreateJob, onUpdateJob, onDeleteJob, onLogout }: DashboardPageProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + const canSubmit = name.trim().length >= 3; + + return ( +
+
+

Meta Core Feature: Optimization Command Queue

+

+ Schedule, monitor, and adjust high-performance optimization workflows for enterprise + delivery. +

+
+ +