Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 11 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@ repos:
- id: shellcheck
args: ["--severity=error"]
files: ^scripts/.*\.sh$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.8
- repo: local
hooks:
- id: ruff
name: ruff check
entry: uv run --locked --extra dev ruff check --force-exclude
language: system
types_or: [python, pyi, jupyter]
require_serial: true
args: ["--fix"]
- id: ruff-format
name: ruff format
entry: uv run --locked --extra dev ruff format --force-exclude
language: system
types_or: [python, pyi, jupyter, markdown]
require_serial: true
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
Expand Down
2 changes: 1 addition & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The [SDK 1.1.2 to 1.1.5 changes](https://github.com/a2aproject/a2a-python/compar
| Protobuf | Supports `>=6.33.5,<8.0`. The default CI uses locked 7.36.2; a separate Python 3.11 job installs 6.33.6 and disables uv synchronization throughout the test process tree, so nested protocol probes also exercise protobuf 6. SDK 1.1.5 removes the former protobuf 7 blocker, so the obsolete Dependabot ignore is removed. |
| Security floors | `click>=8.3.3` and `pyasn1>=0.6.4` remain necessary because upstream metadata permits older versions. They are runtime requirements so wheel and sdist installs enforce them without uv-specific constraints. Installed-metadata tests protect these floors and the SQLAlchemy asyncio extra. |

The missing-import typing overrides for `google.protobuf.*` and `jsonrpc.*` remain limited to those third-party modules. Runtime support is validated by the SDK and transport regressions, not inferred from the typing overrides. Ruff's pre-commit revision is aligned with the lockfile version when upgrading the lint toolchain.
The missing-import typing overrides for `google.protobuf.*` and `jsonrpc.*` remain limited to those third-party modules. Runtime support is validated by the SDK and transport regressions, not inferred from the typing overrides. Ruff's local pre-commit hooks run through `uv run --locked --extra dev`, so `uv.lock` is the single version source for linting and formatting.

## Contract Honesty

Expand Down
2 changes: 2 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ The supported persistence profile is one `opencode-a2a` application process usin

The runtime configures local durability-oriented SQLite connection settings (`WAL`, `busy_timeout`, `synchronous=NORMAL`) and creates missing parent directories for file-backed database paths.

Empty SQLite URLs, `:memory:` URLs, and `mode=memory` URIs explicitly use `StaticPool` to share one database connection across checkouts. This preserves the existing ephemeral database behavior when SQLAlchemy changes its automatic pool selection. File-backed databases retain SQLAlchemy's default connection pool and the file hardening described below.

The package declares `sqlalchemy[asyncio]` so standalone installs such as `uv tool install opencode-a2a` include `greenlet`, which is required by the async database engine. SQLAlchemy 2.1 no longer installs `greenlet` by default; declaring the extra in package metadata keeps wheel installs independent of the repository's `uv.lock`.

Published package metadata also enforces the security floors `click>=8.3.3` and `pyasn1>=0.6.4`. These apply to standalone wheel/sdist installs as well as repository environments; no separate uv constraint configuration is needed.
Expand Down
9 changes: 8 additions & 1 deletion src/opencode_a2a/server/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sqlalchemy import event
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool

from ..config import Settings

Expand Down Expand Up @@ -162,7 +163,13 @@ def build_database_engine(settings: Settings) -> AsyncEngine:
if sqlite_path is not None:
_harden_sqlite_file(sqlite_path)

engine = create_async_engine(database_url)
url = make_url(database_url)
is_memory_database = (
not url.database or url.database == ":memory:" or url.query.get("mode") == "memory"
)
# Keep one connection for the lifetime of an in-memory database. SQLAlchemy 2.1
# deprecates inferring StaticPool from mode=memory; file databases keep the default.
engine = create_async_engine(database_url, poolclass=StaticPool if is_memory_database else None)
if sqlite_path is not None:
event.listen(
engine.sync_engine,
Expand Down
53 changes: 32 additions & 21 deletions tests/server/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ async def test_build_database_engine_configures_sqlite_pragmas(tmp_path) -> None
engine = build_database_engine(settings)

try:
async with engine.connect() as conn:
async with engine.connect() as conn, engine.connect() as other:
assert (await conn.get_raw_connection()).driver_connection is not (
await other.get_raw_connection()
).driver_connection
journal_mode = (await conn.exec_driver_sql("PRAGMA journal_mode")).scalar_one()
busy_timeout = (await conn.exec_driver_sql("PRAGMA busy_timeout")).scalar_one()
synchronous = (await conn.exec_driver_sql("PRAGMA synchronous")).scalar_one()
Expand All @@ -41,29 +44,37 @@ async def test_build_database_engine_configures_sqlite_pragmas(tmp_path) -> None


@pytest.mark.asyncio
async def test_build_database_engine_skips_hardening_for_memory_database() -> None:
settings = make_settings(
a2a_task_store_database_url="sqlite+aiosqlite:///:memory:",
)
engine = build_database_engine(settings)
try:
async with engine.connect() as conn:
assert (await conn.exec_driver_sql("SELECT 1")).scalar_one() == 1
finally:
await engine.dispose()

@pytest.mark.filterwarnings("error::sqlalchemy.exc.SADeprecationWarning")
@pytest.mark.parametrize(
"database_url",
[
"sqlite+aiosqlite://",
"sqlite+aiosqlite:///:memory:",
"sqlite+aiosqlite:///file:memdb_shared?mode=memory&cache=shared&uri=true",
"sqlite+aiosqlite:///file:memdb_private?mode=memory&uri=true",
],
ids=["implicit-memory", "memory", "shared-memory-uri", "private-memory-uri"],
)
async def test_memory_database_reuses_connection_and_preserves_state(
database_url: str, monkeypatch
) -> None:
def reject_file_hardening(_path: Path) -> None:
pytest.fail("Memory databases must not perform filesystem hardening")

@pytest.mark.asyncio
async def test_build_database_engine_skips_hardening_for_inline_memory_file_uri() -> None:
settings = make_settings(
a2a_task_store_database_url=(
"sqlite+aiosqlite:///file:memdb_shared?mode=memory&cache=shared&uri=true"
),
)
monkeypatch.setattr(database_module, "_harden_sqlite_file", reject_file_hardening)
settings = make_settings(a2a_task_store_database_url=database_url)
engine = build_database_engine(settings)
try:
async with engine.connect() as conn:
assert (await conn.exec_driver_sql("SELECT 1")).scalar_one() == 1
async with engine.begin() as conn:
await conn.exec_driver_sql("CREATE TABLE sample (value INTEGER)")
await conn.exec_driver_sql("INSERT INTO sample VALUES (42)")

# Even overlapping checkouts must share the connection that owns the database.
async with engine.connect() as first, engine.connect() as second:
assert (await first.get_raw_connection()).driver_connection is (
await second.get_raw_connection()
).driver_connection
assert (await second.exec_driver_sql("SELECT value FROM sample")).scalar_one() == 42
finally:
await engine.dispose()

Expand Down
Loading
Loading