Switches the production backing store from SQLite to PostgreSQL 16, per DR-2.
The migration trigger is two concurrent writers on the my-deepagent ORM
tables — which first appears with FastAPI (M8-Py). Doing the cut now keeps
the surface area small while M8-Py is still planning.
Production deps: `asyncpg`, `psycopg[binary]`, `langgraph-checkpoint-postgres`.
Test deps: `aiosqlite` (the bulk of unit + integration tests stay on sqlite
tmp_path for speed; the E2E suite and the new checkpointer tests exercise
the live Postgres path).
Highlights
- `persistence/db.py`: dialect-aware connect listener. SQLite still gets
WAL + busy_timeout=5000 + foreign_keys=ON; Postgres gets `SET TIME ZONE 'UTC'`.
Added `Database.dialect_name` + `drop_schema` (test-only).
- `persistence/checkpointer.py`: SqliteSaver → AsyncPostgresSaver. API is
now async (`async with`) and takes a connection string. SQLAlchemy URL
prefixes (`+asyncpg`, `+psycopg`) are auto-stripped to a plain libpq DSN
(`_to_psycopg_dsn` helper, 4 unit tests).
- `persistence/upsert.py` (new): `insert_for(session)` — dialect-aware UPSERT
helper. Picks `postgresql.insert` or `sqlite.insert` based on the bound
engine. Replaces 5 hardcoded `sqlite_insert` call sites in `budget.py`,
`recovery.py`, `cli/doctor.py`.
- `persistence/models.py`: `RunRow` partial unique index declares both
`postgresql_where=` and `sqlite_where=` for cross-dialect correctness.
- `config.py`: default `database_url` now
`postgresql+asyncpg://devflow:devflow@localhost:55432/mydeepagent`. v3
`devflow` DB preserved untouched; v4 lives in a fresh `mydeepagent` DB.
- `cli/doctor.py` check 8: dialect-aware DB liveness probe. Postgres path
runs `SELECT 1` (pg_isready equivalent); SQLite keeps `PRAGMA integrity_check`.
- `alembic/env.py`: env-aware URL resolution (`MYDEEPAGENT_DATABASE_URL` >
`DATABASE_URL` > default). Async driver prefixes are mapped to the sync
equivalents alembic needs.
- `alembic/versions/9f2a6c79667e_v0_2_baseline_schema_postgres.py` (new):
fresh baseline autogenerated against live Postgres. Old SQLite migrations
(`79945fdc2649`, `839f2233e346`) deleted — v0.2 starts a clean history.
- `tests/conftest.py` (new): `pg_db_url` async fixture creates a fresh DB
per test against docker-compose `devflow-postgres` and drops it on
teardown after terminating lingering backends.
- `tests/integration/test_checkpointer.py`: rewritten for AsyncPostgresSaver
(4 pure DSN-converter unit tests + 3 async context-manager integration tests).
- `tests/integration/test_e2e_workflow.py`: switched to `pg_db_url`. Real
OpenRouter E2E now exercises the production Postgres path end-to-end.
Recovery
- Previous SQLite database at the platformdirs data_dir is NOT auto-migrated;
v0.1.0 was the only release that wrote to it. Set
`MYDEEPAGENT_DATABASE_URL=sqlite+aiosqlite:///<path>` to read it.
- The v3 `devflow` Postgres DB is preserved untouched (separate database
name); to inspect: `psql -h localhost -p 55432 -U devflow -d devflow`.
Gates
- ruff check + ruff format --check + mypy --strict: PASS (102 source files)
- pytest non-E2E: 576 PASS (5.46 s)
- pytest E2E real OpenRouter on Postgres: 1 PASS (122.93 s, ~$0.05/run)
--no-verify: lefthook still TS-only (deleted in 0e61b2d but still queryable
in git history).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
3.8 KiB
Python
112 lines
3.8 KiB
Python
"""Application configuration loaded from env, .env, and TOML file via pydantic-settings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from platformdirs import PlatformDirs
|
|
from pydantic import Field, ValidationError, field_validator
|
|
from pydantic_settings import (
|
|
BaseSettings,
|
|
PydanticBaseSettingsSource,
|
|
SettingsConfigDict,
|
|
TomlConfigSettingsSource,
|
|
)
|
|
|
|
from .enums import ErrorClass
|
|
from .errors import MyDeepAgentError
|
|
|
|
_DIRS = PlatformDirs("my-deepagent", "user", roaming=False)
|
|
|
|
|
|
class Config(BaseSettings):
|
|
"""Frozen application config. Source priority (high -> low): CLI/env, .env, TOML, defaults."""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="MYDEEPAGENT_",
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
toml_file=Path(_DIRS.user_config_dir) / "config.toml",
|
|
frozen=True,
|
|
extra="ignore",
|
|
)
|
|
|
|
# storage
|
|
# v0.2 PR #1: Postgres is the production default. Local docker-compose ships
|
|
# a `devflow-postgres` container on port 55432 with credentials
|
|
# devflow / devflow. The v3 `devflow` DB is preserved untouched; v4 lives in
|
|
# a fresh `mydeepagent` DB. Tests may override via MYDEEPAGENT_DATABASE_URL.
|
|
database_url: str = Field(
|
|
default="postgresql+asyncpg://devflow:devflow@localhost:55432/mydeepagent"
|
|
)
|
|
workspace_root: Path = Field(default_factory=Path.cwd)
|
|
data_dir: Path = Field(default_factory=lambda: Path(_DIRS.user_data_dir))
|
|
config_dir: Path = Field(default_factory=lambda: Path(_DIRS.user_config_dir))
|
|
state_dir: Path = Field(default_factory=lambda: Path(_DIRS.user_state_dir))
|
|
|
|
# logging / i18n
|
|
log_level: Literal["trace", "debug", "info", "warn", "error"] = "info"
|
|
lang: Literal["ko", "en"] = "ko"
|
|
|
|
# providers
|
|
openrouter_api_key: str | None = None
|
|
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
|
|
|
# observability
|
|
langsmith_tracing: bool = False
|
|
langsmith_api_key: str | None = None
|
|
langsmith_project: str = "my-deepagent"
|
|
|
|
# budget
|
|
budget_daily_usd: float = Field(default=5.0, ge=0)
|
|
budget_daily_warn_usd: float = Field(default=3.0, ge=0)
|
|
budget_run_usd: float = Field(default=1.0, ge=0)
|
|
budget_run_warn_usd: float = Field(default=0.5, ge=0)
|
|
budget_on_hit: Literal["prompt", "block", "warn_continue"] = "prompt"
|
|
|
|
# defaults
|
|
default_persona: str = "default-interactive"
|
|
|
|
@field_validator("workspace_root", "data_dir", "config_dir", "state_dir")
|
|
@classmethod
|
|
def _expand(cls, v: Path) -> Path:
|
|
return Path(v).expanduser().resolve()
|
|
|
|
@classmethod
|
|
def settings_customise_sources(
|
|
cls,
|
|
settings_cls: type[BaseSettings],
|
|
init_settings: PydanticBaseSettingsSource,
|
|
env_settings: PydanticBaseSettingsSource,
|
|
dotenv_settings: PydanticBaseSettingsSource,
|
|
file_secret_settings: PydanticBaseSettingsSource,
|
|
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
# priority: init > env > dotenv > toml > defaults
|
|
return (
|
|
init_settings,
|
|
env_settings,
|
|
dotenv_settings,
|
|
TomlConfigSettingsSource(settings_cls),
|
|
file_secret_settings,
|
|
)
|
|
|
|
|
|
def load_config(**overrides: object) -> Config:
|
|
"""Load Config with optional kwargs override.
|
|
|
|
Wraps pydantic ValidationError in MyDeepAgentError(fatal, config_invalid) per plan §18.
|
|
"""
|
|
try:
|
|
return Config(**overrides) # type: ignore[arg-type]
|
|
except ValidationError as e:
|
|
raise MyDeepAgentError(
|
|
ErrorClass.FATAL,
|
|
"config_invalid",
|
|
message=f"config validation failed: {e}",
|
|
recovery_hint=(
|
|
"check .env, environment variables, and ~/.config/my-deepagent/config.toml"
|
|
),
|
|
cause=e,
|
|
) from e
|