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
43 changes: 34 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ jobs:
test-sqlite-unit:
name: Test SQLite Unit (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 30
needs: [static-checks]
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -99,7 +98,6 @@ jobs:
test-sqlite-integration:
name: Test SQLite Integration (${{ matrix.os }}, Python ${{ matrix.python-version }})
timeout-minutes: 45
needs: [static-checks]
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -146,7 +144,7 @@ jobs:
test-postgres-unit:
name: Test Postgres Unit (Python ${{ matrix.python-version }})
timeout-minutes: 30
needs: [static-checks]
if: github.event_name != 'pull_request' || matrix.python-version == '3.12'
strategy:
fail-fast: false
matrix:
Expand All @@ -155,8 +153,22 @@ jobs:
- python-version: "3.13"
- python-version: "3.14"
runs-on: ubuntu-latest

# Note: No services section needed - testcontainers handles Postgres in Docker
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: basic_memory_user
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: basic_memory_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U basic_memory_user -d basic_memory_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -190,7 +202,7 @@ jobs:
test-postgres-integration:
name: Test Postgres Integration (Python ${{ matrix.python-version }})
timeout-minutes: 45
needs: [static-checks]
if: github.event_name != 'pull_request' || matrix.python-version == '3.12'
strategy:
fail-fast: false
matrix:
Expand All @@ -199,8 +211,22 @@ jobs:
- python-version: "3.13"
- python-version: "3.14"
runs-on: ubuntu-latest

# Note: No services section needed - testcontainers handles Postgres in Docker
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: basic_memory_user
POSTGRES_PASSWORD: dev_password
POSTGRES_DB: basic_memory_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U basic_memory_user -d basic_memory_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -234,7 +260,6 @@ jobs:
test-semantic:
name: Test Semantic (Python 3.12)
timeout-minutes: 45
needs: [static-checks]
runs-on: ubuntu-latest

steps:
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,5 +442,9 @@ With GitHub integration, the development workflow includes:
3. **Branch management** - Claude can create feature branches for implementations
4. **Documentation maintenance** - Claude can keep documentation updated as the code evolves
5. **Code Commits**: ALWAYS sign off commits with `git commit -s`
6. **Pull Request Titles**: PR titles must follow the semantic format enforced by `.github/workflows/pr-title.yml`: `type(scope): summary`
- Allowed types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`
- Allowed scopes: `core`, `cli`, `api`, `mcp`, `sync`, `ui`, `deps`, `installer`
- Example: `fix(cli): propagate cloud workspace routing`

This level of integration represents a new paradigm in AI-human collaboration, where the AI assistant becomes a full-fledged team member rather than just a tool for generating code snippets.
55 changes: 46 additions & 9 deletions src/basic_memory/cli/commands/cloud/cloud_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from basic_memory.cli.commands.cloud.api_client import make_api_request
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import resolve_configured_workspace
from basic_memory.schemas.cloud import (
CloudProjectList,
CloudProjectCreateRequest,
Expand All @@ -16,8 +17,25 @@ class CloudUtilsError(Exception):
pass


def _workspace_headers(
*,
project_name: str | None = None,
workspace: str | None = None,
) -> dict[str, str]:
"""Build optional workspace headers using the CLI config resolution chain."""
resolved_workspace = resolve_configured_workspace(
project_name=project_name,
workspace=workspace,
)
if resolved_workspace is None:
return {}
return {"X-Workspace-ID": resolved_workspace}


async def fetch_cloud_projects(
*,
project_name: str | None = None,
workspace: str | None = None,
api_request=make_api_request,
) -> CloudProjectList:
"""Fetch list of projects from cloud API.
Expand All @@ -30,7 +48,11 @@ async def fetch_cloud_projects(
config = config_manager.config
host_url = config.cloud_host.rstrip("/")

response = await api_request(method="GET", url=f"{host_url}/proxy/v2/projects/")
response = await api_request(
method="GET",
url=f"{host_url}/proxy/v2/projects/",
headers=_workspace_headers(project_name=project_name, workspace=workspace),
)

return CloudProjectList.model_validate(response.json())
except Exception as e:
Expand All @@ -40,12 +62,14 @@ async def fetch_cloud_projects(
async def create_cloud_project(
project_name: str,
*,
workspace: str | None = None,
api_request=make_api_request,
) -> CloudProjectCreateResponse:
"""Create a new project on cloud.

Args:
project_name: Name of project to create
workspace: Optional workspace override for tenant-scoped project creation

Returns:
CloudProjectCreateResponse with project details from API
Expand All @@ -67,7 +91,10 @@ async def create_cloud_project(
response = await api_request(
method="POST",
url=f"{host_url}/proxy/v2/projects/",
headers={"Content-Type": "application/json"},
headers={
"Content-Type": "application/json",
**_workspace_headers(project_name=project_name, workspace=workspace),
},
json_data=project_data.model_dump(),
)

Expand All @@ -91,18 +118,28 @@ async def sync_project(project_name: str, force_full: bool = False) -> None:
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e


async def project_exists(project_name: str, *, api_request=make_api_request) -> bool:
async def project_exists(
project_name: str,
*,
workspace: str | None = None,
api_request=make_api_request,
) -> bool:
"""Check if a project exists on cloud.

Args:
project_name: Name of project to check
workspace: Optional workspace override for tenant-scoped project lookup

Returns:
True if project exists, False otherwise

Raises:
CloudUtilsError: If the project list cannot be fetched from cloud
"""
try:
projects = await fetch_cloud_projects(api_request=api_request)
project_names = {p.name for p in projects.projects}
return project_name in project_names
except Exception:
return False
projects = await fetch_cloud_projects(
project_name=project_name,
workspace=workspace,
api_request=api_request,
)
project_names = {p.name for p in projects.projects}
return project_name in project_names
17 changes: 10 additions & 7 deletions src/basic_memory/cli/commands/cloud/project_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def _require_cloud_credentials(config) -> None:

async def _get_cloud_project(name: str) -> ProjectItem | None:
"""Fetch a project by name from the cloud API."""
async with get_client() as client:
async with get_client(project_name=name) as client:
projects_list = await ProjectClient(client).list_projects()
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
Expand Down Expand Up @@ -129,9 +129,9 @@ def sync_project_command(
if not dry_run:

async def _trigger_db_sync():
async with get_client() as client:
async with get_client(project_name=name) as client:
return await ProjectClient(client).sync(
project_data.external_id, force_full=True
project_data.external_id, force_full=False
)

try:
Expand Down Expand Up @@ -195,7 +195,10 @@ def bisync_project_command(
# Update config — sync_entry is guaranteed non-None because
# _get_sync_project validated local_sync_path (which comes from sync_entry)
sync_entry = config.projects.get(name)
assert sync_entry is not None
if sync_entry is None:
raise RuntimeError(
f"Sync entry for project '{name}' unexpectedly missing after validation"
)
sync_entry.last_sync = datetime.now()
sync_entry.bisync_initialized = True
ConfigManager().save_config(config)
Expand All @@ -204,9 +207,9 @@ def bisync_project_command(
if not dry_run:

async def _trigger_db_sync():
async with get_client() as client:
async with get_client(project_name=name) as client:
return await ProjectClient(client).sync(
project_data.external_id, force_full=True
project_data.external_id, force_full=False
)

try:
Expand Down Expand Up @@ -320,7 +323,7 @@ def setup_project_sync(

async def _verify_project_exists():
"""Verify the project exists on cloud by listing all projects."""
async with get_client() as client:
async with get_client(project_name=name) as client:
projects_list = await ProjectClient(client).list_projects()
project_names = [p.name for p in projects_list.projects]
if name not in project_names:
Expand Down
30 changes: 24 additions & 6 deletions src/basic_memory/cli/commands/cloud/upload_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Upload CLI commands for basic-memory projects."""

from functools import partial
from pathlib import Path

import typer
Expand All @@ -8,12 +9,16 @@
from basic_memory.cli.app import cloud_app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.cloud.cloud_utils import (
CloudUtilsError,
create_cloud_project,
project_exists,
sync_project,
)
from basic_memory.cli.commands.cloud.upload import upload_path
from basic_memory.mcp.async_client import get_cloud_control_plane_client
from basic_memory.mcp.async_client import (
get_cloud_control_plane_client,
resolve_configured_workspace,
)

console = Console()

Expand Down Expand Up @@ -73,12 +78,20 @@ def upload(
"""

async def _upload():
resolved_workspace = resolve_configured_workspace(project_name=project)

try:
project_already_exists = await project_exists(project, workspace=resolved_workspace)
except CloudUtilsError as e:
console.print(f"[red]Failed to check cloud project '{project}': {e}[/red]")
raise typer.Exit(1)

# Check if project exists
if not await project_exists(project):
if not project_already_exists:
if create_project:
console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
try:
await create_cloud_project(project)
await create_cloud_project(project, workspace=resolved_workspace)
console.print(f"[green]Created project '{project}'[/green]")
except Exception as e:
console.print(f"[red]Failed to create project: {e}[/red]")
Expand Down Expand Up @@ -106,7 +119,10 @@ async def _upload():
verbose=verbose,
use_gitignore=not no_gitignore,
dry_run=dry_run,
client_cm_factory=get_cloud_control_plane_client,
client_cm_factory=partial(
get_cloud_control_plane_client,
workspace=resolved_workspace,
),
)
if not success:
console.print("[red]Upload failed[/red]")
Expand All @@ -117,8 +133,10 @@ async def _upload():
else:
console.print(f"[green]Successfully uploaded to '{project}'[/green]")

# Sync project if requested (skip on dry run)
# Force full scan after bisync to ensure database is up-to-date with synced files
# Sync project if requested (skip on dry run).
# Trigger: upload adds new files the watcher has not observed locally.
# Why: force_full ensures those freshly uploaded files are indexed immediately.
# Outcome: upload keeps its eager reindex while sync/bisync stay incremental.
if sync and not dry_run:
console.print(f"[blue]Syncing project '{project}'...[/blue]")
try:
Expand Down
Loading
Loading