Skip to content
Open
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
Empty file added .codex
Empty file.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,14 @@ celerybeat.pid

# Environments
.env
.env.local-api
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
src/server/.env.local-api

# Spyder project settings
.spyderproject
Expand Down Expand Up @@ -215,7 +217,6 @@ pyrightconfig.json
go.work

# End of https://www.toptal.com/developers/gitignore/api/go

# OpenClaw plugin runtime data
src/packages/openclaw/data/

Expand Down
5 changes: 5 additions & 0 deletions src/client/acontext-py/src/acontext/types/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ class Session(BaseModel):
disable_task_tracking: bool = Field(
False, description="Whether task tracking is disabled for this session"
)
# This field is populated lazily from the first real task description so
# clients can show a friendly session name without extra requests.
display_title: str | None = Field(
None, description="Optional generated display title for the session"
)
configs: dict[str, Any] | None = Field(
None, description="Session configuration dictionary"
)
Expand Down
21 changes: 21 additions & 0 deletions src/client/acontext-py/tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,27 @@ async def test_async_sessions_create_with_use_uuid_and_user(
assert kwargs["json_data"]["configs"] == {"agent": "bot1"}


@patch("acontext.async_client.AcontextAsyncClient.request", new_callable=AsyncMock)
@pytest.mark.asyncio
async def test_async_sessions_create_parses_display_title(
mock_request, async_client: AcontextAsyncClient
) -> None:
"""Test that display_title from API is available on Session model."""
# The async client should preserve the optional title field on create responses.
mock_request.return_value = {
"id": "session-id",
"project_id": "project-id",
"display_title": "Plan migration rollout",
"configs": {},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
}

session = await async_client.sessions.create()

assert session.display_title == "Plan migration rollout"


@patch("acontext.async_client.AcontextAsyncClient.request", new_callable=AsyncMock)
@pytest.mark.asyncio
async def test_async_store_message_with_files_uses_multipart_payload(
Expand Down
20 changes: 20 additions & 0 deletions src/client/acontext-py/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,26 @@ def test_sessions_create_with_use_uuid_and_user(
assert kwargs["json_data"]["configs"] == {"agent": "bot1"}


@patch("acontext.client.AcontextClient.request")
def test_sessions_create_parses_display_title(
mock_request, client: AcontextClient
) -> None:
"""Test that display_title from API is available on Session model."""
# The sync client should preserve the optional title field on create responses.
mock_request.return_value = {
"id": "session-id",
"project_id": "project-id",
"display_title": "Plan migration rollout",
"configs": {},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
}

session = client.sessions.create()

assert session.display_title == "Plan migration rollout"


@patch("acontext.client.AcontextClient.request")
def test_sessions_list_filter_by_configs(mock_request, client: AcontextClient) -> None:
"""Test that filter_by_configs is JSON-encoded and sent to API."""
Expand Down
3 changes: 3 additions & 0 deletions src/client/acontext-ts/src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const SessionSchema = z.object({
project_id: z.string(),
user_id: z.string().nullable().optional(),
disable_task_tracking: z.boolean(),
// This optional title is generated from the first task and may be absent
// for newly created sessions.
display_title: z.string().nullable().optional(),
configs: z.record(z.string(), z.unknown()).nullable(),
created_at: z.string(),
updated_at: z.string(),
Expand Down
11 changes: 11 additions & 0 deletions src/client/acontext-ts/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ describe('AcontextClient Unit Tests', () => {
expect(session.id).toBe(customUuid);
});

test('should parse display_title in session response', async () => {
// This confirms the optional field survives the client-side parser.
const createdSession = mockSession({
display_title: 'Plan migration rollout',
});
client.mock().onPost('/session', () => createdSession);

const session = await client.sessions.create();
expect(session.display_title).toBe('Plan migration rollout');
});

test('should store a message in acontext format', async () => {
const sessionId = 'test-session-id';
const storedMessage = mockMessage({
Expand Down
4 changes: 4 additions & 0 deletions src/client/acontext-ts/tests/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export function mockSession(overrides?: Partial<{
project_id: string;
user_id: string | null;
disable_task_tracking: boolean;
display_title: string | null;
configs: Record<string, unknown> | null;
created_at: string;
updated_at: string;
Expand All @@ -241,6 +242,9 @@ export function mockSession(overrides?: Partial<{
project_id: overrides?.project_id ?? mockId(),
user_id: overrides?.user_id ?? null,
disable_task_tracking: overrides?.disable_task_tracking ?? false,
// Keep the mock aligned with the API response shape so session parsers see
// the same optional title field as real responses.
display_title: overrides?.display_title ?? null,
configs: overrides?.configs ?? {},
created_at: overrides?.created_at ?? now,
updated_at: overrides?.updated_at ?? now,
Expand Down
2 changes: 2 additions & 0 deletions src/server/api/go/internal/modules/model/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type Session struct {
ProjectID uuid.UUID `gorm:"type:uuid;not null;index" json:"project_id"`
UserID *uuid.UUID `gorm:"type:uuid;index" json:"user_id"`
DisableTaskTracking bool `gorm:"not null;default:false" json:"disable_task_tracking"`
// Generated UI label derived from the first task description when available.
DisplayTitle *string `gorm:"type:text" json:"display_title"`
Configs datatypes.JSONMap `gorm:"type:jsonb;index:idx_sessions_configs,type:gin" swaggertype:"object" json:"configs"`

CreatedAt time.Time `gorm:"autoCreateTime;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
Expand Down
3 changes: 3 additions & 0 deletions src/server/core/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ RUN uv sync --frozen --no-dev --no-cache-dir

# Copy the application code
COPY ./acontext_core /app/acontext_core
COPY ./alembic /app/alembic
COPY ./alembic.ini /app/alembic.ini
COPY ./scripts /app/scripts
COPY ./routers /app/routers
COPY ./api.py /app

Expand Down
14 changes: 13 additions & 1 deletion src/server/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,28 @@ cp .env.example .env

```bash
# current path: ./src/server/core
uv run python -m acontext_core.infra.alembic upgrade-head
uv run -m fastapi dev
```

- Launch Core in prod mode

```bash
# current path: ./src/server/core
uv run python -m acontext_core.infra.alembic upgrade-head
uv run -m uvicorn api:app --host 0.0.0.0 --port 8000
```

- Existing database bootstrap

```bash
# current path: ./src/server/core
uv run python -m acontext_core.infra.alembic upgrade-head
```

If the database already has the old core tables but no Alembic history yet, the
migration runner stamps the baseline revision once and then upgrades to `head`.

- Service Healthcheck
```bash
curl http://localhost:8000/health
Expand All @@ -49,4 +61,4 @@ curl http://localhost:8000/health
```bash
# current path: ./src/server/core
uv run -m pytest
```
```
99 changes: 99 additions & 0 deletions src/server/core/acontext_core/infra/alembic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from __future__ import annotations

import asyncio
import sys
from pathlib import Path

from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool

from ..env import DEFAULT_CORE_CONFIG

ALEMBIC_ROOT = Path(__file__).resolve().parents[2]
ALEMBIC_INI_PATH = ALEMBIC_ROOT / "alembic.ini"
ALEMBIC_SCRIPT_LOCATION = ALEMBIC_ROOT / "alembic"
BASELINE_REVISION = "0001_core_schema_baseline"
BASELINE_MARKER_TABLES = {"projects", "sessions", "tasks", "messages"}


def _normalize_database_url(database_url: str) -> str:
if database_url.startswith("postgres://"):
return database_url.replace("postgres://", "postgresql://", 1)
return database_url


def get_alembic_async_database_url(database_url: str | None = None) -> str:
raw_database_url = _normalize_database_url(
database_url or DEFAULT_CORE_CONFIG.database_url
)
if raw_database_url.startswith("postgresql+asyncpg://"):
return raw_database_url
if raw_database_url.startswith("postgresql://"):
return raw_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
raise ValueError(f"Unsupported database URL for Alembic: {raw_database_url}")


def _build_alembic_config(database_url: str | None = None) -> Config:
config = Config(str(ALEMBIC_INI_PATH))
config.set_main_option("script_location", str(ALEMBIC_SCRIPT_LOCATION))
config.set_main_option("sqlalchemy.url", get_alembic_async_database_url(database_url))
return config


async def _get_database_table_names(database_url: str | None = None) -> set[str]:
engine = create_async_engine(
get_alembic_async_database_url(database_url),
poolclass=NullPool,
)
try:
async with engine.connect() as connection:
return set(
await connection.run_sync(
lambda sync_connection: inspect(sync_connection).get_table_names()
)
)
finally:
await engine.dispose()


def _stamp_and_upgrade(database_url: str | None, should_stamp_baseline: bool) -> None:
config = _build_alembic_config(database_url)
if should_stamp_baseline:
command.stamp(config, BASELINE_REVISION)
command.upgrade(config, "head")


async def upgrade_database_to_head(database_url: str | None = None) -> None:
table_names = await _get_database_table_names(database_url)
has_version_table = "alembic_version" in table_names
legacy_marker_tables = table_names & BASELINE_MARKER_TABLES

if not has_version_table and legacy_marker_tables:
if legacy_marker_tables != BASELINE_MARKER_TABLES:
raise RuntimeError(
"Found a partial core schema without Alembic history. "
"Finish the previous migration work or stamp the database manually."
)
await asyncio.to_thread(_stamp_and_upgrade, database_url, True)
return

await asyncio.to_thread(_stamp_and_upgrade, database_url, False)


def main(argv: list[str] | None = None) -> int:
args = argv or sys.argv[1:]
command_name = args[0] if args else "upgrade-head"

if command_name != "upgrade-head":
print(f"Unsupported command: {command_name}", file=sys.stderr)
return 2

asyncio.run(upgrade_database_to_head())
return 0


if __name__ == "__main__":
raise SystemExit(main())
28 changes: 2 additions & 26 deletions src/server/core/acontext_core/infra/db.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import traceback
import os
from typing import Optional
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
Expand All @@ -14,8 +13,6 @@
from sqlalchemy import text
from sqlalchemy.exc import DisconnectionError, OperationalError

# from ..schema.orm import Base
from ..schema.orm import ORM_BASE
from ..env import LOG as logger
from ..env import DEFAULT_CORE_CONFIG

Expand Down Expand Up @@ -49,7 +46,6 @@ def __init__(self, database_url: Optional[str] = None):

logger.debug(f"SQLAlchemy Engine URL: {self.database_url}")
self._engine: AsyncEngine | None = self._create_engine()
self._table_created: bool = False
self._sessionmaker: async_sessionmaker[AsyncSession] | None = (
async_sessionmaker(
bind=self.engine,
Expand Down Expand Up @@ -187,25 +183,6 @@ async def health_check(self) -> bool:
logger.error(f"Database health check failed: {e}")
return False

async def create_tables(self) -> None:
"""Create all tables defined in the ORM models."""
if self._table_created:
return
async with self.get_session_context() as db_session:
await db_session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
logger.info("pgvector extension init")
async with self.engine.begin() as conn:
await conn.run_sync(ORM_BASE.metadata.create_all)

self._table_created = True

async def drop_tables(self) -> None:
"""Drop all tables defined in the ORM models."""
async with self.engine.begin() as conn:
await conn.run_sync(ORM_BASE.metadata.drop_all)
logger.warning("All database tables dropped")
self._table_created = False

async def close(self) -> None:
"""Close the database engine and all connections."""
if self._engine:
Expand Down Expand Up @@ -233,10 +210,9 @@ def get_pool_status(self) -> dict[str, int | str]:

# Convenience functions
async def init_database() -> None:
"""Initialize the database (create tables)."""
await DB_CLIENT.create_tables()
"""Initialize database connectivity after migrations have already run."""
assert await DB_CLIENT.health_check(), "Database health check failed"
logger.info(f"Database created successfully {DB_CLIENT.get_pool_status()}")
logger.info(f"Database initialized successfully {DB_CLIENT.get_pool_status()}")


async def close_database() -> None:
Expand Down
Loading
Loading