Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
15d24b7
chore: update environment configuration and dependencies
raychrisgdp Jan 2, 2026
8b53130
feat(api): implement task management endpoints and error handling
raychrisgdp Jan 3, 2026
3352377
feat: implement PR-016 observability baseline
raychrisgdp Jan 3, 2026
75d65b0
fix: resolve lint errors in test files
raychrisgdp Jan 3, 2026
9e32515
style: apply ruff formatting fixes
raychrisgdp Jan 3, 2026
388ff8f
fix(api): reject null title in TaskUpdate and fix test type annotations
raychrisgdp Jan 3, 2026
081f3a0
Merge pull request #5 from raychrisgdp/pr-002-task-crud-api
raychrisgdp Jan 3, 2026
bc55279
feat: implement PR-016 observability baseline
raychrisgdp Jan 3, 2026
63520eb
fix: resolve lint errors in test files
raychrisgdp Jan 3, 2026
af7d7fb
style: apply ruff formatting fixes
raychrisgdp Jan 3, 2026
f520b30
fix: ensure logger level is set in middleware tests
raychrisgdp Jan 3, 2026
464f2ce
fix: ensure logger propagation in middleware tests
raychrisgdp Jan 3, 2026
27b6a05
test: improve middleware test logger configuration
raychrisgdp Jan 3, 2026
ffedd64
merge: resolve conflicts with main branch
raychrisgdp Jan 3, 2026
7ccb0b2
style: apply ruff formatting fixes
raychrisgdp Jan 3, 2026
8af84be
test: use custom handler for middleware log tests to fix parallel exe…
raychrisgdp Jan 3, 2026
91cbcd4
test: fix middleware log tests for parallel execution with custom han…
raychrisgdp Jan 3, 2026
70a6f68
docs: enhance observability section in README and USER_GUIDE
raychrisgdp Jan 3, 2026
6b35ca4
build: update Makefile and CI workflow for dependency management
raychrisgdp Jan 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
# TaskGenie Configuration
# Copy this file to .env and edit as needed

# App metadata
APP_NAME=TaskGenie
APP_VERSION=0.1.0
DEBUG=true
DEBUG=false

# Server
HOST=127.0.0.1
PORT=8080

DATABASE_URL=sqlite+aiosqlite:///./data/taskgenie.db

LLM_PROVIDER=openrouter
LLM_API_KEY=
LLM_MODEL=anthropic/claude-3-haiku

GMAIL_ENABLED=false
GMAIL_CREDENTIALS_PATH=

GITHUB_TOKEN=
GITHUB_USERNAME=

NOTIFICATIONS_ENABLED=true
NOTIFICATION_SCHEDULE=["24h", "6h"]
# Database (optional - defaults to ~/.taskgenie/data/taskgenie.db)
# DATABASE_URL=sqlite+aiosqlite:///./data/taskgenie.db
13 changes: 6 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,13 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Create virtual environment
run: uv venv

- name: Install dependencies
run: uv pip install -e ".[dev]"

- name: Lint
run: make lint
run: |
if [ -f uv.lock ]; then
uv sync --extra dev
else
uv venv && uv pip install --python .venv/bin/python -e ".[dev]"
fi

- name: Tests
run: make test-cov
21 changes: 17 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,34 @@ help:
@echo "Usage:"
@echo " make dev Install dev dependencies (includes API for tests)"
@echo " make install-all Install all optional dependencies"
@echo " make lock Update uv.lock file after modifying pyproject.toml dependencies"
@echo " make hooks Install pre-commit git hooks"
@echo " make precommit Run docs check + pre-commit on all files"
@echo " make lint Run ruff lint"
@echo " make format Run ruff formatter"
@echo " make typecheck Run mypy"
@echo " make test Run pytest"
@echo " make test-cov Run pytest with coverage"
@echo " make test-cov Run precommit + pytest with coverage (matches CI)"
@echo " make docs-check Validate docs links/naming"
@echo " make check Run lint + typecheck + test"

dev:
uv pip install -e ".[dev]"
@if [ -f uv.lock ]; then \
uv sync --extra dev; \
else \
uv venv && uv pip install --python .venv/bin/python -e ".[dev]"; \
fi

install-all:
uv pip install -e ".[all]"
@if [ -f uv.lock ]; then \
uv sync --all-extras; \
else \
uv venv && uv pip install --python .venv/bin/python -e ".[all]"; \
fi

# Update lock file when dependencies change (run this after modifying pyproject.toml)
lock:
uv lock

hooks: dev
uv run pre-commit install
Expand All @@ -41,7 +54,7 @@ typecheck:
test:
uv run pytest -n 4

test-cov:
test-cov: precommit
uv run pytest -n 4 --cov=backend --cov-report=term-missing

docs-check:
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ uv run tgenie --help
- Start the FastAPI app and call `GET /health`.
- Configure settings via `.env` (`backend/config.py`).
- Use the CLI entrypoint (`tgenie --help`) to view placeholder commands.
- Check system health via `GET /api/v1/telemetry` (see `docs/USER_GUIDE.md` for observability settings).

### Observability

TaskGenie provides structured JSON logging and a telemetry endpoint for monitoring:

- **Logging**: Configure via `LOG_LEVEL` (default: `INFO`) and `LOG_FILE_PATH` (default: `~/.taskgenie/logs/taskgenie.jsonl`)
- **Telemetry**: Enable/disable via `TELEMETRY_ENABLED` (default: `true`). Endpoint: `/api/v1/telemetry`

See `docs/USER_GUIDE.md` for detailed observability configuration.

## Try It Now

Expand Down
11 changes: 11 additions & 0 deletions backend/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""API v1 package.

Author:
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

from __future__ import annotations

from backend.api.v1 import telemetry

__all__: list[str] = ["telemetry"]
118 changes: 118 additions & 0 deletions backend/api/v1/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Task API endpoints.

Author:
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

from __future__ import annotations

from datetime import datetime

from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from backend.database import get_db
from backend.schemas.task import TaskCreate, TaskListResponse, TaskPriority, TaskResponse, TaskStatus, TaskUpdate
from backend.services.task_service import create_task, delete_task, get_task, list_tasks, update_task

router = APIRouter(prefix="/tasks", tags=["tasks"])


@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task_endpoint(task_data: TaskCreate, session: AsyncSession = Depends(get_db)) -> TaskResponse:
"""Create a new task.

Args:
task_data: Task creation data.
session: Database session.

Returns:
TaskResponse: Created task.
"""
return await create_task(session, task_data)


@router.get("", response_model=TaskListResponse)
async def list_tasks_endpoint(
status: TaskStatus | None = Query(None, description="Filter by status"),
priority: TaskPriority | None = Query(None, description="Filter by priority"),
due_before: datetime | None = Query(None, description="Filter tasks due before this datetime"),
due_after: datetime | None = Query(None, description="Filter tasks due after this datetime"),
limit: int = Query(50, ge=1, description="Maximum number of results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
session: AsyncSession = Depends(get_db),
) -> TaskListResponse:
"""List tasks with optional filters and pagination.

Args:
status: Filter by status (pending, in_progress, completed).
priority: Filter by priority (low, medium, high, critical).
due_before: Filter tasks with eta before this datetime.
due_after: Filter tasks with eta after this datetime.
limit: Maximum number of results (default: 50).
offset: Pagination offset (default: 0).
session: Database session.

Returns:
TaskListResponse: Paginated task list.
"""
return await list_tasks(
session=session,
status=status.value if status else None,
priority=priority.value if priority else None,
due_before=due_before,
due_after=due_after,
limit=limit,
offset=offset,
)


@router.get("/{task_id}", response_model=TaskResponse)
async def get_task_endpoint(task_id: str, session: AsyncSession = Depends(get_db)) -> TaskResponse:
"""Get a task by ID.

Args:
task_id: Task ID.
session: Database session.

Returns:
TaskResponse: Task data.

Raises:
TaskNotFoundError: If task is not found (handled by FastAPI exception handler).
"""
return await get_task(session, task_id)


@router.patch("/{task_id}", response_model=TaskResponse)
async def update_task_endpoint(
task_id: str, task_data: TaskUpdate, session: AsyncSession = Depends(get_db)
) -> TaskResponse:
"""Update a task.

Args:
task_id: Task ID.
task_data: Task update data.
session: Database session.

Returns:
TaskResponse: Updated task.

Raises:
TaskNotFoundError: If task is not found (handled by FastAPI exception handler).
"""
return await update_task(session, task_id, task_data)


@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task_endpoint(task_id: str, session: AsyncSession = Depends(get_db)) -> None:
"""Delete a task.

Args:
task_id: Task ID.
session: Database session.

Raises:
TaskNotFoundError: If task is not found (handled by FastAPI exception handler).
"""
await delete_task(session, task_id)
99 changes: 99 additions & 0 deletions backend/api/v1/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Telemetry endpoint for system health and metrics.

Author:
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

from __future__ import annotations

import time
from typing import Any

from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

from backend.config import get_settings
from backend.database import get_db

# Track app start time for uptime calculation
_app_start_time = time.time()

router = APIRouter()


async def get_migration_version(db: AsyncSession) -> str | None:
"""Get current Alembic migration version from database.

Args:
db: Database session.

Returns:
Migration version string or None if table doesn't exist or query fails.
"""
try:
result = await db.execute(text("SELECT version_num FROM alembic_version LIMIT 1"))
row = result.fetchone()
if row:
return str(row[0])
return None
except Exception:
# Table doesn't exist or query failed
return None


async def check_db_health(db: AsyncSession) -> tuple[bool, str | None]:
"""Check database connectivity.

Args:
db: Database session.

Returns:
Tuple of (connected: bool, error: str | None).
"""
try:
await db.execute(text("SELECT 1"))
return True, None
except Exception as exc:
return False, str(exc)


@router.get("/telemetry")
async def get_telemetry(db: AsyncSession = Depends(get_db)) -> dict[str, Any]:
"""Get system telemetry and health metrics.

Returns:
JSON object with status, version, uptime, DB health, and optional metrics.
Always returns 200 status code; status is reported in payload.
"""
settings = get_settings()

# Check DB health
db_connected, db_error = await check_db_health(db)

# Get migration version
migration_version = await get_migration_version(db)

# Calculate uptime
uptime_s = int(time.time() - _app_start_time)

# Determine overall status
status = "ok" if db_connected else "degraded"

# Build response
response: dict[str, Any] = {
"status": status,
"version": settings.app_version,
"uptime_s": uptime_s,
"db": {"connected": db_connected, "migration_version": migration_version},
"optional": {
"event_queue_size": None, # PR-013 not implemented
"agent_runs_active": None, # PR-014 not implemented
},
}

# Add error message if degraded
if not db_connected and db_error:
response["db"]["error"] = db_error

return response
4 changes: 4 additions & 0 deletions backend/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
from rich.console import Console

from backend.cli import db
from backend.logging import setup_logging

# Setup structured logging for CLI
setup_logging()

app = typer.Typer(help="TaskGenie CLI (implementation in progress)")
console = Console()
Expand Down
29 changes: 29 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ def settings_customise_sources(
notifications_enabled: bool = Field(default=True, alias="NOTIFICATIONS_ENABLED")
notification_schedule: list[str] = Field(default_factory=lambda: ["24h", "6h"], alias="NOTIFICATION_SCHEDULE")

# Logging
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
telemetry_enabled: bool = Field(default=True, alias="TELEMETRY_ENABLED")
log_file_path: str | None = Field(default=None, alias="LOG_FILE_PATH")

@field_validator("app_data_dir", mode="before")
@classmethod
def expand_app_data_dir(cls, v: str | Path) -> Path:
Expand Down Expand Up @@ -250,6 +255,30 @@ def logs_path(self) -> Path:
"""Get canonical logs directory path."""
return self.app_data_dir / "logs"

def get_log_level(self) -> str:
"""Get resolved log level (DEBUG when debug=True, otherwise configured level).

Returns:
Log level string (DEBUG if debug=True, otherwise log_level.upper()).
"""
if self.debug:
return "DEBUG"
return self.log_level.upper()

def get_log_file_path(self) -> Path:
"""Get resolved log file path with default if not set.

File logging is always enabled. If LOG_FILE_PATH is not set, uses default
path ({app_data_dir}/logs/taskgenie.jsonl).

Returns:
Path to log file (configured path or default).
"""
if self.log_file_path:
return Path(self.log_file_path).expanduser()
# Default to {app_data_dir}/logs/taskgenie.jsonl
return self.app_data_dir / "logs" / "taskgenie.jsonl"


@lru_cache(maxsize=1)
def get_settings() -> Settings:
Expand Down
Loading