mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41d12cba95 | |||
| ff5d872a8c | |||
| 96ee4eafd2 | |||
| b109b7337f | |||
| 36b51b676e | |||
| 9af320187c | |||
| 5a34a420c9 | |||
| a7e2368f9e | |||
| c755127317 | |||
| 94c04ee456 | |||
| d4ed02ba74 |
@@ -253,6 +253,31 @@ async_client.set_client_factory(your_custom_factory)
|
||||
|
||||
See SPEC-16 for full context manager refactor details.
|
||||
|
||||
### Release Process
|
||||
|
||||
Releases are driven by `just release` / `just beta` — never by a bare `git tag`. The recipes bump version metadata, run pre-flight checks, commit, tag, and push. GitHub Actions then publishes to PyPI and updates the Homebrew formula.
|
||||
|
||||
**Stable release:**
|
||||
|
||||
```
|
||||
just release v0.21.3
|
||||
```
|
||||
|
||||
The recipe runs `just lint` + `just typecheck`, then updates `__version__` in `src/basic_memory/__init__.py` and `"version"` in `server.json` (MCP registry metadata), commits as `chore: update version to X.Y.Z for vX.Y.Z release`, creates the `vX.Y.Z` tag, and pushes both the commit and the tag to `origin/main`. After the tag lands, the `Release` workflow builds the package, publishes to PyPI, creates the GitHub release with auto-generated notes, and updates the Homebrew formula. The recipe finishes by printing the post-release tasks the workflow doesn't cover.
|
||||
|
||||
**Beta release:** `just beta v0.21.3b1` — same flow with a beta-suffixed tag. PyPI consumers install with `pip install basic-memory --pre`.
|
||||
|
||||
**Development builds:** every commit to `main` publishes a `0.21.3.dev26+468a22f`-style version to PyPI automatically via `.github/workflows/dev-release.yml`. No human action.
|
||||
|
||||
**Do not tag releases by hand.** A bare `git tag vX.Y.Z` skips the in-code version bump. Package metadata is still correct (uv-dynamic-versioning derives it from the git tag) but `basic-memory --version` reports the previous release, which is what happened with v0.21.2 → v0.21.3.
|
||||
|
||||
**Post-release tasks** the recipe surfaces but doesn't run:
|
||||
- `docs.basicmemory.com` — add notes to `src/pages/latest-releases.mdx`
|
||||
- `basicmachines.co` — bump version in `src/components/sections/hero.tsx`
|
||||
- MCP Registry — `mcp-publisher publish` from the repo root
|
||||
|
||||
See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `changelog.md` alongside it) for the full release + post-release runbook, including the slash commands.
|
||||
|
||||
## BASIC MEMORY PRODUCT USAGE
|
||||
|
||||
### Knowledge Structure
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.21.5 (2026-05-26)
|
||||
|
||||
Workspace/project routing fixes for MCP, plus a SQLite vector reindex stability fix.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#854**: MCP project listing now keeps duplicate cloud project rows distinct by
|
||||
workspace, so only the selected workspace row inherits local project state.
|
||||
- **#853**: `write_note` returns workspace-qualified permalinks for cloud
|
||||
workspace writes, allowing follow-up `memory://` reads to route back to the
|
||||
correct workspace/project.
|
||||
- **#852**: Full SQLite vector reindex now loads `sqlite-vec` before dropping
|
||||
`vec0` virtual tables, preventing reindex crashes.
|
||||
- **#838**: Local ASGI database initialization is preloaded so MCP routing can
|
||||
safely enter local project contexts.
|
||||
|
||||
## v0.21.1 (2026-05-16)
|
||||
|
||||
CI-only release. No user-facing changes.
|
||||
|
||||
@@ -224,40 +224,6 @@ See `test-int/BENCHMARKS.md` for detailed benchmark documentation.
|
||||
- **Fixtures**: Use async pytest fixtures for setup and teardown
|
||||
- **Markers**: Use `@pytest.mark.benchmark` for benchmarks, `@pytest.mark.slow` for slow tests
|
||||
|
||||
## Release Process
|
||||
|
||||
Basic Memory uses automatic versioning based on git tags with `uv-dynamic-versioning`. Here's how releases work:
|
||||
|
||||
### Version Management
|
||||
- **Development versions**: Automatically generated from git commits (e.g., `0.12.4.dev26+468a22f`)
|
||||
- **Beta releases**: Created by tagging with beta suffixes (e.g., `git tag v0.13.0b1`)
|
||||
- **Stable releases**: Created by tagging with version numbers (e.g., `git tag v0.13.0`)
|
||||
|
||||
### Release Workflows
|
||||
|
||||
#### Development Builds
|
||||
- Automatically published to PyPI on every commit to `main`
|
||||
- Version format: `0.12.4.dev26+468a22f` (base version + dev + commit count + hash)
|
||||
- Users install with: `pip install basic-memory --pre --force-reinstall`
|
||||
|
||||
#### Beta Releases
|
||||
1. Create and push a beta tag: `git tag v0.13.0b1 && git push origin v0.13.0b1`
|
||||
2. GitHub Actions automatically builds and publishes to PyPI
|
||||
3. Users install with: `pip install basic-memory --pre`
|
||||
|
||||
#### Stable Releases
|
||||
1. Create and push a version tag: `git tag v0.13.0 && git push origin v0.13.0`
|
||||
2. GitHub Actions automatically:
|
||||
- Builds the package with version `0.13.0`
|
||||
- Creates GitHub release with auto-generated notes
|
||||
- Publishes to PyPI
|
||||
3. Users install with: `pip install basic-memory`
|
||||
|
||||
### For Contributors
|
||||
- No manual version bumping required
|
||||
- Versions are automatically derived from git tags
|
||||
- Focus on code changes, not version management
|
||||
|
||||
## Creating Issues
|
||||
|
||||
If you're planning to work on something, please create an issue first to discuss the approach. Include:
|
||||
|
||||
+60
-52
@@ -1,6 +1,6 @@
|
||||
# Basic Memory Cloud CLI Guide
|
||||
|
||||
The Basic Memory Cloud CLI provides seamless integration between local and cloud knowledge bases using **project-scoped synchronization**. Each project can optionally sync with the cloud, giving you fine-grained control over what syncs and where.
|
||||
The Basic Memory Cloud CLI provides seamless integration between local and cloud knowledge bases using **project-scoped synchronization**. Personal workspaces can optionally use local rclone mirrors, giving you fine-grained control over what syncs and where.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -8,9 +8,13 @@ The cloud CLI enables you to:
|
||||
- **Authenticate cloud access** - OAuth/API key credentials are stored locally for cloud operations
|
||||
- **Project-scoped sync** - Each project independently manages its sync configuration
|
||||
- **Explicit operations** - Sync only what you want, when you want
|
||||
- **Bidirectional sync** - Keep local and cloud in sync with rclone bisync
|
||||
- **Bidirectional sync** - Keep Personal workspace local mirrors and cloud in sync with rclone bisync
|
||||
- **Offline access** - Work locally, sync when ready
|
||||
|
||||
Team workspaces are accessed through the cloud API/MCP and do not support local
|
||||
multi-user rclone sync/bisync. Use `bm project list --workspace <workspace>` to
|
||||
inspect Team projects.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before using Basic Memory Cloud, you need:
|
||||
@@ -39,7 +43,7 @@ If you attempt to log in without an active subscription, you'll receive a "Subsc
|
||||
**Projects can exist in three states:**
|
||||
|
||||
1. **Cloud-only** - Project exists on cloud, no local copy
|
||||
2. **Cloud + Local (synced)** - Project has a local working directory that syncs
|
||||
2. **Cloud + Local (synced)** - Personal workspace project has a local working directory that syncs
|
||||
3. **Local-only** - Project exists locally and is not routed to cloud
|
||||
|
||||
**Example:**
|
||||
@@ -55,8 +59,8 @@ bm project add work --cloud --local-path ~/work-notes
|
||||
bm project add temp --cloud # No local sync
|
||||
|
||||
# Now you can sync individually (after initial --resync):
|
||||
bm project bisync --name research
|
||||
bm project bisync --name work
|
||||
bm cloud bisync --name research
|
||||
bm cloud bisync --name work
|
||||
# temp stays cloud-only
|
||||
```
|
||||
|
||||
@@ -87,7 +91,7 @@ Apply OSS discount code `{{OSS_DISCOUNT_CODE}}` during checkout to receive 20% o
|
||||
|
||||
### 2. Set Up Sync
|
||||
|
||||
Install rclone and configure credentials:
|
||||
Install rclone and configure Personal workspace sync credentials:
|
||||
|
||||
```bash
|
||||
bm cloud setup
|
||||
@@ -99,7 +103,7 @@ bm cloud setup
|
||||
3. Generates scoped S3 credentials for sync
|
||||
4. Configures single rclone remote: `basic-memory-cloud`
|
||||
|
||||
**Result:** You're ready to sync projects. No sync directories created yet - those come with project setup.
|
||||
**Result:** You're ready to sync Personal workspace projects. No sync directories created yet - those come with project setup.
|
||||
|
||||
Rclone setup uses package managers such as Homebrew, MacPorts, apt, dnf, yum, pacman,
|
||||
zypper, snap, winget, Chocolatey, or Scoop when available. It does not run remote
|
||||
@@ -108,7 +112,7 @@ manual install instructions.
|
||||
|
||||
### 3. Add Projects with Sync
|
||||
|
||||
Create projects with optional local sync paths:
|
||||
Create Personal workspace projects with optional local sync paths:
|
||||
|
||||
```bash
|
||||
# Create cloud project without local sync
|
||||
@@ -137,10 +141,10 @@ Establish the initial sync baseline. **Best practice:** Always preview with `--d
|
||||
|
||||
```bash
|
||||
# Step 1: Preview the initial sync (recommended)
|
||||
bm project bisync --name research --resync --dry-run
|
||||
bm cloud bisync --name research --resync --dry-run
|
||||
|
||||
# Step 2: If all looks good, run the actual sync
|
||||
bm project bisync --name research --resync
|
||||
bm cloud bisync --name research --resync
|
||||
```
|
||||
|
||||
**What happens under the covers:**
|
||||
@@ -167,7 +171,7 @@ This will effectively make both Path1 and Path2 filesystems contain a matching s
|
||||
After the first sync, just run bisync without `--resync`:
|
||||
|
||||
```bash
|
||||
bm project bisync --name research
|
||||
bm cloud bisync --name research
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -235,7 +239,7 @@ bm project add research --cloud --local-path ~/Documents/research
|
||||
- Stores sync config in `~/.basic-memory/config.json`
|
||||
- Prepares for bisync (but doesn't sync yet)
|
||||
|
||||
**Result:** Project ready to sync. Run `bm project bisync --name research --resync` to establish baseline.
|
||||
**Result:** Project ready to sync. Run `bm cloud bisync --name research --resync` to establish baseline.
|
||||
|
||||
**Use case 3: Add sync to existing cloud project**
|
||||
|
||||
@@ -292,20 +296,24 @@ For MCP stdio, routing is always local.
|
||||
|
||||
## File Synchronization
|
||||
|
||||
Local rclone sync/bisync is supported only for Personal workspaces. Team
|
||||
workspaces are cloud-only for local CLI usage; access them through cloud API/MCP
|
||||
routing instead of a local multi-user rclone mirror.
|
||||
|
||||
### Understanding the Sync Commands
|
||||
|
||||
**There are three sync-related commands:**
|
||||
|
||||
1. `bm project sync` - One-way: local → cloud (make cloud match local)
|
||||
2. `bm project bisync` - Two-way: local ↔ cloud (recommended)
|
||||
3. `bm project check` - Verify files match (no changes)
|
||||
1. `bm cloud sync` - One-way: local → cloud (make cloud match local)
|
||||
2. `bm cloud bisync` - Two-way: local ↔ cloud (recommended)
|
||||
3. `bm cloud check` - Verify files match (no changes)
|
||||
|
||||
### One-Way Sync: Local → Cloud
|
||||
|
||||
**Use case:** You made changes locally and want to push to cloud (overwrite cloud).
|
||||
|
||||
```bash
|
||||
bm project sync --name research
|
||||
bm cloud sync --name research
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -327,10 +335,10 @@ bm project sync --name research
|
||||
|
||||
```bash
|
||||
# First time - establish baseline
|
||||
bm project bisync --name research --resync
|
||||
bm cloud bisync --name research --resync
|
||||
|
||||
# Subsequent syncs
|
||||
bm project bisync --name research
|
||||
bm cloud bisync --name research
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -349,7 +357,7 @@ echo "Local change" > ~/Documents/research/notes.md
|
||||
# Cloud now has: "Cloud change"
|
||||
|
||||
# Run bisync
|
||||
bm project bisync --name research
|
||||
bm cloud bisync --name research
|
||||
|
||||
# Result: Newer file wins (based on modification time)
|
||||
# If cloud was more recent, cloud version kept
|
||||
@@ -366,7 +374,7 @@ bm project bisync --name research
|
||||
**Use case:** Check if local and cloud match without making changes.
|
||||
|
||||
```bash
|
||||
bm project check --name research
|
||||
bm cloud check --name research
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -378,7 +386,7 @@ bm project check --name research
|
||||
|
||||
```bash
|
||||
# One-way check (faster)
|
||||
bm project check --name research --one-way
|
||||
bm cloud check --name research --one-way
|
||||
```
|
||||
|
||||
### Preview Changes (Dry Run)
|
||||
@@ -386,7 +394,7 @@ bm project check --name research --one-way
|
||||
**Use case:** See what would change without actually syncing.
|
||||
|
||||
```bash
|
||||
bm project bisync --name research --dry-run
|
||||
bm cloud bisync --name research --dry-run
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
@@ -432,20 +440,20 @@ bm project add work --cloud --local-path ~/work-notes
|
||||
bm project add personal --cloud --local-path ~/personal
|
||||
|
||||
# Establish baselines
|
||||
bm project bisync --name research --resync
|
||||
bm project bisync --name work --resync
|
||||
bm project bisync --name personal --resync
|
||||
bm cloud bisync --name research --resync
|
||||
bm cloud bisync --name work --resync
|
||||
bm cloud bisync --name personal --resync
|
||||
|
||||
# Daily workflow: sync everything
|
||||
bm project bisync --name research
|
||||
bm project bisync --name work
|
||||
bm project bisync --name personal
|
||||
bm cloud bisync --name research
|
||||
bm cloud bisync --name work
|
||||
bm cloud bisync --name personal
|
||||
```
|
||||
|
||||
**Future:** `--all` flag will sync all configured projects:
|
||||
|
||||
```bash
|
||||
bm project bisync --all # Coming soon
|
||||
bm cloud bisync --all # Coming soon
|
||||
```
|
||||
|
||||
### Mixed Usage
|
||||
@@ -462,8 +470,8 @@ bm project add archive --cloud
|
||||
bm project add temp-notes --cloud
|
||||
|
||||
# Sync only the configured ones
|
||||
bm project bisync --name research
|
||||
bm project bisync --name work
|
||||
bm cloud bisync --name research
|
||||
bm cloud bisync --name work
|
||||
|
||||
# Archive and temp-notes stay cloud-only
|
||||
```
|
||||
@@ -661,7 +669,7 @@ code ~/.basic-memory/.bmignore
|
||||
echo "*.tmp" >> ~/.basic-memory/.bmignore
|
||||
|
||||
# Next sync uses updated patterns
|
||||
bm project bisync --name research
|
||||
bm cloud bisync --name research
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -724,7 +732,7 @@ bm cloud login
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
bm project bisync --name research --resync
|
||||
bm cloud bisync --name research --resync
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -747,7 +755,7 @@ bm project bisync --name research --resync
|
||||
echo "# Research Notes" > ~/Documents/research/README.md
|
||||
|
||||
# Now run bisync
|
||||
bm project bisync --name research --resync
|
||||
bm cloud bisync --name research --resync
|
||||
```
|
||||
|
||||
**Why this happens:** Bisync creates listing files that track the state of each side. When both directories are completely empty, these listing files are considered invalid by rclone.
|
||||
@@ -764,10 +772,10 @@ bm project bisync --name research --resync
|
||||
|
||||
```bash
|
||||
# Clear bisync state
|
||||
bm project bisync-reset research
|
||||
bm cloud bisync-reset research
|
||||
|
||||
# Re-establish baseline
|
||||
bm project bisync --name research --resync
|
||||
bm cloud bisync --name research --resync
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
@@ -787,16 +795,16 @@ bm project bisync --name research --resync
|
||||
|
||||
```bash
|
||||
# Check what would be deleted
|
||||
bm project bisync --name research --dry-run
|
||||
bm cloud bisync --name research --dry-run
|
||||
|
||||
# If correct, establish new baseline
|
||||
bm project bisync --name research --resync
|
||||
bm cloud bisync --name research --resync
|
||||
```
|
||||
|
||||
**Solution 2:** Use one-way sync if you know local is correct:
|
||||
|
||||
```bash
|
||||
bm project sync --name research
|
||||
bm cloud sync --name research
|
||||
```
|
||||
|
||||
### Project Not Configured for Sync
|
||||
@@ -809,7 +817,7 @@ bm project sync --name research
|
||||
|
||||
```bash
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
bm project bisync --name research --resync
|
||||
bm cloud bisync --name research --resync
|
||||
```
|
||||
|
||||
### Connection Issues
|
||||
@@ -881,19 +889,19 @@ bm project set-local <name> # Revert project to local mode
|
||||
|
||||
```bash
|
||||
# One-way sync (local → cloud)
|
||||
bm project sync --name <project>
|
||||
bm project sync --name <project> --dry-run
|
||||
bm project sync --name <project> --verbose
|
||||
bm cloud sync --name <project>
|
||||
bm cloud sync --name <project> --dry-run
|
||||
bm cloud sync --name <project> --verbose
|
||||
|
||||
# Two-way sync (local ↔ cloud) - Recommended
|
||||
bm project bisync --name <project> # After first --resync
|
||||
bm project bisync --name <project> --resync # First time / force baseline
|
||||
bm project bisync --name <project> --dry-run
|
||||
bm project bisync --name <project> --verbose
|
||||
bm cloud bisync --name <project> # After first --resync
|
||||
bm cloud bisync --name <project> --resync # First time / force baseline
|
||||
bm cloud bisync --name <project> --dry-run
|
||||
bm cloud bisync --name <project> --verbose
|
||||
|
||||
# Integrity check
|
||||
bm project check --name <project>
|
||||
bm project check --name <project> --one-way
|
||||
bm cloud check --name <project>
|
||||
bm cloud check --name <project> --one-way
|
||||
|
||||
# List project files by route
|
||||
bm project ls --name <project> # Default target: local
|
||||
@@ -909,9 +917,9 @@ bm project ls --name <project> --cloud --path <subpath>
|
||||
1. **Authenticate cloud access** - `bm cloud login`
|
||||
2. **Install rclone** - `bm cloud setup`
|
||||
3. **Add projects with sync** - `bm project add research --cloud --local-path ~/Documents/research`
|
||||
4. **Preview first sync** - `bm project bisync --name research --resync --dry-run`
|
||||
5. **Establish baseline** - `bm project bisync --name research --resync`
|
||||
6. **Daily workflow** - `bm project bisync --name research`
|
||||
4. **Preview first sync** - `bm cloud bisync --name research --resync --dry-run`
|
||||
5. **Establish baseline** - `bm cloud bisync --name research --resync`
|
||||
6. **Daily workflow** - `bm cloud bisync --name research`
|
||||
|
||||
**Key benefits:**
|
||||
- ✅ Each project independently syncs (or doesn't)
|
||||
|
||||
+2
-2
@@ -6,12 +6,12 @@
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.21.3",
|
||||
"version": "0.21.5",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.21.3",
|
||||
"version": "0.21.5",
|
||||
"runtimeHint": "uvx",
|
||||
"runtimeArguments": [
|
||||
{"type": "positional", "value": "basic-memory"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.21.3"
|
||||
__version__ = "0.21.5"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -95,7 +95,10 @@ def sync_project_command(
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""One-way sync: local -> cloud (make cloud identical to local).
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
One-way sync: local -> cloud (make cloud identical to local).
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
|
||||
Example:
|
||||
bm cloud sync --name research
|
||||
@@ -143,7 +146,10 @@ def bisync_project_command(
|
||||
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""Two-way sync: local <-> cloud (bidirectional sync).
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
Two-way sync: local <-> cloud (bidirectional sync).
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
|
||||
Examples:
|
||||
bm cloud bisync --name research --resync # First time
|
||||
@@ -203,7 +209,10 @@ def check_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to check"),
|
||||
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
|
||||
) -> None:
|
||||
"""Verify file integrity between local and cloud.
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
Verify file integrity between local and cloud.
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
|
||||
Example:
|
||||
bm cloud check --name research
|
||||
@@ -246,7 +255,10 @@ def check_project_command(
|
||||
def bisync_reset(
|
||||
name: str = typer.Argument(..., help="Project name to reset bisync state for"),
|
||||
) -> None:
|
||||
"""Clear bisync state for a project.
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
Clear bisync state for a project.
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
|
||||
This removes the bisync metadata files, forcing a fresh --resync on next bisync.
|
||||
Useful when bisync gets into an inconsistent state or when remote path changes.
|
||||
@@ -277,7 +289,10 @@ def setup_project_sync(
|
||||
name: str = typer.Argument(..., help="Project name"),
|
||||
local_path: str = typer.Argument(..., help="Local sync directory"),
|
||||
) -> None:
|
||||
"""Configure local sync for an existing cloud project.
|
||||
"""Personal workspace local mirror only.
|
||||
|
||||
Configure local sync for an existing cloud project.
|
||||
Not supported for Team workspaces - use cloud API/MCP routing instead.
|
||||
|
||||
Example:
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
|
||||
@@ -447,8 +447,12 @@ class TaskScheduler(Protocol):
|
||||
|
||||
|
||||
def _log_task_failure(completed: asyncio.Task) -> None:
|
||||
if completed.cancelled():
|
||||
return
|
||||
try:
|
||||
completed.result()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.exception("Background task failed", error=str(exc))
|
||||
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import os
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import AsyncIterator, Callable, Optional
|
||||
from asyncio import Lock
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
from typing import TYPE_CHECKING, Annotated, Any, AsyncIterator, Callable, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from httpx import ASGITransport, AsyncClient, Timeout
|
||||
from loguru import logger
|
||||
|
||||
import logfire
|
||||
from basic_memory.config import ConfigManager, ProjectMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"]
|
||||
_MISSING_STATE_VALUE = object()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PreparedLocalAsgiDatabase:
|
||||
active_count: int
|
||||
previous_engine: object
|
||||
previous_session_maker: object
|
||||
dependency_context: AbstractAsyncContextManager[LocalDatabaseState]
|
||||
|
||||
|
||||
_prepared_local_asgi_database_lock = RLock()
|
||||
_prepared_local_asgi_database_prepare_locks: dict[FastAPI, Lock] = {}
|
||||
_prepared_local_asgi_databases: dict[FastAPI, _PreparedLocalAsgiDatabase] = {}
|
||||
|
||||
|
||||
def _force_local_mode() -> bool:
|
||||
"""Check if local mode is forced via environment variable."""
|
||||
@@ -34,15 +57,12 @@ def _build_timeout() -> Timeout:
|
||||
)
|
||||
|
||||
|
||||
def _asgi_client(timeout: Timeout) -> AsyncClient:
|
||||
"""Create a local ASGI client."""
|
||||
# Import on first local-client use so CLI help/version paths can import
|
||||
# routing helpers without constructing the full FastAPI router graph.
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient:
|
||||
"""Create a local ASGI client for an already-prepared FastAPI app."""
|
||||
from basic_memory.workspace_context import workspace_permalink_headers
|
||||
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app),
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
timeout=timeout,
|
||||
# Local ASGI calls still cross the HTTP boundary, so request handlers need
|
||||
@@ -51,6 +71,169 @@ def _asgi_client(timeout: Timeout) -> AsyncClient:
|
||||
)
|
||||
|
||||
|
||||
def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock:
|
||||
"""Get the async lock that serializes first-time DB preparation for an app."""
|
||||
with _prepared_local_asgi_database_lock:
|
||||
prepare_lock = _prepared_local_asgi_database_prepare_locks.get(app)
|
||||
if prepare_lock is None:
|
||||
prepare_lock = Lock()
|
||||
_prepared_local_asgi_database_prepare_locks[app] = prepare_lock
|
||||
return prepare_lock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatabaseState]:
|
||||
"""Resolve database state for a local ASGI request."""
|
||||
from fastapi.dependencies.utils import get_dependant, solve_dependencies
|
||||
|
||||
from basic_memory.deps import get_engine_factory
|
||||
|
||||
async def resolve_database_state(
|
||||
database_state: Annotated[LocalDatabaseState, Depends(get_engine_factory)],
|
||||
) -> LocalDatabaseState:
|
||||
return database_state
|
||||
|
||||
scope: dict[str, Any] = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": "/",
|
||||
"raw_path": b"/",
|
||||
"root_path": "",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"client": ("testclient", 50000),
|
||||
"server": ("testserver", 80),
|
||||
"app": app,
|
||||
"path_params": {},
|
||||
}
|
||||
|
||||
async with AsyncExitStack() as request_stack, AsyncExitStack() as function_stack:
|
||||
scope["fastapi_inner_astack"] = request_stack
|
||||
scope["fastapi_function_astack"] = function_stack
|
||||
request = Request(scope)
|
||||
dependant = get_dependant(path="/", call=resolve_database_state)
|
||||
solved = await solve_dependencies(
|
||||
request=request,
|
||||
dependant=dependant,
|
||||
dependency_overrides_provider=app,
|
||||
async_exit_stack=request_stack,
|
||||
embed_body_fields=False,
|
||||
)
|
||||
if solved.errors:
|
||||
raise RuntimeError(f"Failed to resolve local ASGI database dependency: {solved.errors}")
|
||||
|
||||
yield await resolve_database_state(**solved.values)
|
||||
|
||||
|
||||
def _retain_prepared_local_asgi_database(app: FastAPI) -> bool:
|
||||
"""Retain an active local ASGI database preparation if one exists."""
|
||||
with _prepared_local_asgi_database_lock:
|
||||
active = _prepared_local_asgi_databases.get(app)
|
||||
if active is None:
|
||||
return False
|
||||
|
||||
active.active_count += 1
|
||||
return True
|
||||
|
||||
|
||||
def _install_prepared_local_asgi_database(
|
||||
app: FastAPI,
|
||||
database_state: LocalDatabaseState,
|
||||
dependency_context: AbstractAsyncContextManager[LocalDatabaseState],
|
||||
) -> None:
|
||||
"""Install local ASGI database state after dependency resolution."""
|
||||
with _prepared_local_asgi_database_lock:
|
||||
active = _prepared_local_asgi_databases.get(app)
|
||||
if active is not None:
|
||||
raise RuntimeError("Local ASGI database state installed while another state is active")
|
||||
|
||||
previous_engine = getattr(app.state, "engine", _MISSING_STATE_VALUE)
|
||||
previous_session_maker = getattr(app.state, "session_maker", _MISSING_STATE_VALUE)
|
||||
engine, session_maker = database_state
|
||||
|
||||
app.state.engine = engine
|
||||
app.state.session_maker = session_maker
|
||||
_prepared_local_asgi_databases[app] = _PreparedLocalAsgiDatabase(
|
||||
active_count=1,
|
||||
previous_engine=previous_engine,
|
||||
previous_session_maker=previous_session_maker,
|
||||
dependency_context=dependency_context,
|
||||
)
|
||||
|
||||
|
||||
def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value: object) -> None:
|
||||
"""Restore a FastAPI app.state attribute captured before local ASGI preparation."""
|
||||
if previous_value is _MISSING_STATE_VALUE:
|
||||
if hasattr(app.state, name):
|
||||
delattr(app.state, name)
|
||||
else:
|
||||
setattr(app.state, name, previous_value)
|
||||
|
||||
|
||||
def _release_prepared_local_asgi_database(
|
||||
app: FastAPI,
|
||||
) -> AbstractAsyncContextManager[LocalDatabaseState] | None:
|
||||
"""Release local ASGI database state after a client context exits."""
|
||||
with _prepared_local_asgi_database_lock:
|
||||
active = _prepared_local_asgi_databases.get(app)
|
||||
if active is None:
|
||||
raise RuntimeError("Local ASGI database state released without a matching retain")
|
||||
|
||||
active.active_count -= 1
|
||||
if active.active_count > 0:
|
||||
return None
|
||||
|
||||
del _prepared_local_asgi_databases[app]
|
||||
_restore_local_asgi_state_attribute(app, "engine", active.previous_engine)
|
||||
_restore_local_asgi_state_attribute(
|
||||
app,
|
||||
"session_maker",
|
||||
active.previous_session_maker,
|
||||
)
|
||||
return active.dependency_context
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _prepared_local_asgi_database(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Initialize local ASGI database state before the first request."""
|
||||
prepare_lock = _get_prepared_local_asgi_database_prepare_lock(app)
|
||||
async with prepare_lock:
|
||||
if not _retain_prepared_local_asgi_database(app):
|
||||
database_context = _resolve_local_asgi_database(app)
|
||||
database_state = await database_context.__aenter__()
|
||||
try:
|
||||
_install_prepared_local_asgi_database(app, database_state, database_context)
|
||||
except Exception:
|
||||
await database_context.__aexit__(None, None, None)
|
||||
raise
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
database_context = _release_prepared_local_asgi_database(app)
|
||||
if database_context is not None:
|
||||
await database_context.__aexit__(None, None, None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _asgi_client(timeout: Timeout) -> AsyncIterator[AsyncClient]:
|
||||
"""Create a local ASGI client."""
|
||||
# Import on first local-client use so CLI help/version paths can import
|
||||
# routing helpers without constructing the full FastAPI router graph.
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
|
||||
# Trigger: local ASGITransport does not execute FastAPI lifespan startup.
|
||||
# Why: letting request dependencies initialize Postgres can run asyncpg DDL
|
||||
# under Starlette's request loop and trigger CPython's empty-ready-queue race.
|
||||
# Outcome: request handling sees the same app.state database objects as API
|
||||
# lifespan startup would have provided.
|
||||
async with _prepared_local_asgi_database(fastapi_app):
|
||||
async with _build_asgi_client(fastapi_app, timeout) as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def _resolve_cloud_token(config) -> str:
|
||||
"""Resolve cloud token with API key preferred, OAuth fallback."""
|
||||
with logfire.span(
|
||||
@@ -260,7 +443,12 @@ def create_client() -> AsyncClient:
|
||||
|
||||
if _force_local_mode() or not _force_cloud_mode():
|
||||
logger.info("Creating ASGI client for local Basic Memory API")
|
||||
return _asgi_client(timeout)
|
||||
# Deprecated sync path: create_client() cannot await the local ASGI
|
||||
# pre-initialization used by get_client(), so callers that need proper
|
||||
# resource setup should use the async context manager instead.
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
|
||||
return _build_asgi_client(fastapi_app, timeout)
|
||||
|
||||
logger.info("Creating HTTP client for cloud proxy (legacy create_client path)")
|
||||
config = ConfigManager().config
|
||||
|
||||
@@ -10,7 +10,12 @@ from typing import Literal
|
||||
from fastmcp import Context
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import ConfigManager, has_cloud_credentials
|
||||
from basic_memory.config import (
|
||||
BasicMemoryConfig,
|
||||
ConfigManager,
|
||||
ProjectEntry,
|
||||
has_cloud_credentials,
|
||||
)
|
||||
from basic_memory.mcp.async_client import (
|
||||
_explicit_routing,
|
||||
_force_local_mode,
|
||||
@@ -131,9 +136,66 @@ def _merge_projects(
|
||||
return merged
|
||||
|
||||
|
||||
def _workspace_entry_priority(entry: WorkspaceProjectEntry) -> tuple[bool, int, str, str]:
|
||||
"""Prefer default/personal workspaces when duplicate project permalinks exist."""
|
||||
workspace_type_rank = 0 if entry.workspace.workspace_type == "personal" else 1
|
||||
return (
|
||||
# False sorts before True, so the cloud/default workspace comes first.
|
||||
not entry.workspace.is_default,
|
||||
workspace_type_rank,
|
||||
entry.workspace.name.casefold(),
|
||||
entry.workspace.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def _select_attached_cloud_entry(
|
||||
cloud_entries: tuple[WorkspaceProjectEntry, ...],
|
||||
*,
|
||||
config_entry: ProjectEntry | None,
|
||||
config: BasicMemoryConfig | None,
|
||||
) -> WorkspaceProjectEntry | None:
|
||||
"""Choose the single cloud row that should inherit local project state."""
|
||||
if not cloud_entries:
|
||||
return None
|
||||
|
||||
preferred_workspace_ids: list[str] = []
|
||||
if config_entry and config_entry.workspace_id:
|
||||
preferred_workspace_ids.append(config_entry.workspace_id)
|
||||
if (
|
||||
config
|
||||
and config.default_workspace
|
||||
and config.default_workspace not in preferred_workspace_ids
|
||||
):
|
||||
preferred_workspace_ids.append(config.default_workspace)
|
||||
|
||||
# The configured default workspace can differ from the cloud-side default.
|
||||
# Use the cloud default only after explicit local config preferences.
|
||||
default_workspace_entry = next(
|
||||
(entry for entry in cloud_entries if entry.workspace.is_default),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
default_workspace_entry is not None
|
||||
and default_workspace_entry.workspace.tenant_id not in preferred_workspace_ids
|
||||
):
|
||||
preferred_workspace_ids.append(default_workspace_entry.workspace.tenant_id)
|
||||
|
||||
for workspace_id in preferred_workspace_ids:
|
||||
for entry in cloud_entries:
|
||||
if entry.workspace.tenant_id == workspace_id:
|
||||
return entry
|
||||
|
||||
if len(cloud_entries) == 1:
|
||||
return cloud_entries[0]
|
||||
|
||||
return sorted(cloud_entries, key=_workspace_entry_priority)[0]
|
||||
|
||||
|
||||
def _merge_workspace_projects(
|
||||
local_list: ProjectList | None,
|
||||
cloud_entries: tuple[WorkspaceProjectEntry, ...],
|
||||
*,
|
||||
config: BasicMemoryConfig | None = None,
|
||||
) -> list[dict]:
|
||||
"""Merge local projects with cloud projects from every accessible workspace."""
|
||||
local_by_permalink: dict[str, ProjectItem] = {}
|
||||
@@ -141,20 +203,40 @@ def _merge_workspace_projects(
|
||||
for project in local_list.projects:
|
||||
local_by_permalink[project.permalink] = project
|
||||
|
||||
config_by_permalink: dict[str, ProjectEntry] = {}
|
||||
if config:
|
||||
config_by_permalink = {
|
||||
generate_permalink(project_name): entry
|
||||
for project_name, entry in config.projects.items()
|
||||
}
|
||||
|
||||
cloud_entries_by_permalink: dict[str, list[WorkspaceProjectEntry]] = {}
|
||||
for entry in cloud_entries:
|
||||
cloud_entries_by_permalink.setdefault(entry.project.permalink, []).append(entry)
|
||||
|
||||
attached_entry_by_permalink: dict[str, WorkspaceProjectEntry | None] = {}
|
||||
for permalink in local_by_permalink:
|
||||
attached_entry_by_permalink[permalink] = _select_attached_cloud_entry(
|
||||
tuple(cloud_entries_by_permalink.get(permalink, ())),
|
||||
config_entry=config_by_permalink.get(permalink),
|
||||
config=config,
|
||||
)
|
||||
|
||||
cloud_permalinks = {entry.project.permalink for entry in cloud_entries}
|
||||
merged: list[dict] = []
|
||||
|
||||
for entry in sorted(
|
||||
cloud_entries,
|
||||
key=lambda item: (
|
||||
not item.workspace.is_default,
|
||||
item.workspace.workspace_type != "personal",
|
||||
item.workspace.name.casefold(),
|
||||
item.project.permalink,
|
||||
),
|
||||
key=lambda item: (*_workspace_entry_priority(item), item.project.permalink),
|
||||
):
|
||||
permalink = entry.project.permalink
|
||||
local_proj = local_by_permalink.get(permalink)
|
||||
local_proj = (
|
||||
local_by_permalink.get(permalink)
|
||||
# WorkspaceProjectEntry is a frozen dataclass containing Pydantic
|
||||
# models, so value equality is the intended comparison here.
|
||||
if attached_entry_by_permalink.get(permalink) == entry
|
||||
else None
|
||||
)
|
||||
cloud_proj = entry.project
|
||||
source = "local+cloud" if local_proj else "cloud"
|
||||
local_path = local_proj.path if local_proj else None
|
||||
@@ -339,7 +421,7 @@ async def list_memory_projects(
|
||||
)
|
||||
|
||||
if cloud_entries:
|
||||
merged = _merge_workspace_projects(local_list, cloud_entries)
|
||||
merged = _merge_workspace_projects(local_list, cloud_entries, config=config)
|
||||
else:
|
||||
merged = _merge_projects(
|
||||
local_list,
|
||||
|
||||
@@ -12,7 +12,13 @@ from basic_memory.mcp.project_context import get_project_client, add_project_met
|
||||
from basic_memory.mcp.server import mcp
|
||||
from fastmcp import Context
|
||||
from basic_memory.schemas.base import Entity
|
||||
from basic_memory.utils import coerce_dict, parse_tags, validate_project_path
|
||||
from basic_memory.utils import (
|
||||
build_qualified_permalink_reference,
|
||||
coerce_dict,
|
||||
parse_tags,
|
||||
validate_project_path,
|
||||
)
|
||||
from basic_memory.workspace_context import current_workspace_permalink_context
|
||||
|
||||
# Define TagType as a Union that can accept either a string or a list of strings or None
|
||||
TagType = Union[List[str], str, None]
|
||||
@@ -35,11 +41,7 @@ async def write_note(
|
||||
tags: list[str] | str | None = None,
|
||||
note_type: str = "note",
|
||||
metadata: Annotated[dict | None, BeforeValidator(coerce_dict)] = None,
|
||||
# Force/replace are the file-write idioms models default to.
|
||||
overwrite: Annotated[
|
||||
bool | None,
|
||||
Field(default=None, validation_alias=AliasChoices("overwrite", "force", "replace")),
|
||||
] = None,
|
||||
overwrite: bool | None = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
context: Context | None = None,
|
||||
) -> str | dict:
|
||||
@@ -278,11 +280,20 @@ async def write_note(
|
||||
else:
|
||||
# Re-raise if it's not a conflict error
|
||||
raise # pragma: no cover
|
||||
response_permalink = result.permalink
|
||||
workspace_context = current_workspace_permalink_context()
|
||||
if response_permalink and workspace_context is not None:
|
||||
response_permalink = build_qualified_permalink_reference(
|
||||
active_project.permalink,
|
||||
response_permalink,
|
||||
workspace_permalink=workspace_context.workspace_slug,
|
||||
)
|
||||
|
||||
summary = [
|
||||
f"# {action} note",
|
||||
f"project: {active_project.name}",
|
||||
f"file_path: {result.file_path}",
|
||||
f"permalink: {result.permalink}",
|
||||
f"permalink: {response_permalink}",
|
||||
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
||||
]
|
||||
|
||||
@@ -319,12 +330,12 @@ async def write_note(
|
||||
|
||||
# Log the response with structured data
|
||||
logger.info(
|
||||
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={result.permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
|
||||
f"MCP tool response: tool=write_note project={active_project.name} action={action} permalink={response_permalink} observations_count={len(result.observations)} relations_count={len(result.relations)} resolved_relations={resolved} unresolved_relations={unresolved}"
|
||||
)
|
||||
if output_format == "json":
|
||||
return {
|
||||
"title": result.title,
|
||||
"permalink": result.permalink,
|
||||
"permalink": response_permalink,
|
||||
"file_path": result.file_path,
|
||||
"checksum": result.checksum,
|
||||
"action": action.lower(),
|
||||
|
||||
@@ -620,6 +620,25 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def drop_vector_tables(self) -> None:
|
||||
"""Drop SQLite vector tables on a sqlite-vec-enabled connection."""
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
vector_sql_result = await session.execute(
|
||||
text(
|
||||
"SELECT sql FROM sqlite_master "
|
||||
"WHERE type = 'table' AND name = 'search_vector_embeddings'"
|
||||
)
|
||||
)
|
||||
vector_sql = vector_sql_result.scalar()
|
||||
if vector_sql and "using vec0" in vector_sql.lower():
|
||||
await self._ensure_sqlite_vec_loaded(session)
|
||||
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_embeddings"))
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_chunks"))
|
||||
await session.execute(text("DROP TABLE IF EXISTS search_vector_index"))
|
||||
await session.commit()
|
||||
self._vector_tables_initialized = False
|
||||
|
||||
async def delete_stale_vector_rows(self) -> None:
|
||||
"""Delete vector rows whose source entities no longer exist."""
|
||||
await self._ensure_vector_tables()
|
||||
|
||||
@@ -126,19 +126,23 @@ class SearchService:
|
||||
|
||||
async def reindex_all(self, background_tasks: Optional[BackgroundTasks] = None) -> None:
|
||||
"""Reindex all content from database."""
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
|
||||
logger.info("Starting full reindex")
|
||||
# Clear and recreate search index
|
||||
await self.repository.execute_query(text("DROP TABLE IF EXISTS search_index"), params={})
|
||||
await self.repository.execute_query(
|
||||
text("DROP TABLE IF EXISTS search_vector_embeddings"), params={}
|
||||
)
|
||||
await self.repository.execute_query(
|
||||
text("DROP TABLE IF EXISTS search_vector_chunks"), params={}
|
||||
)
|
||||
await self.repository.execute_query(
|
||||
text("DROP TABLE IF EXISTS search_vector_index"), params={}
|
||||
)
|
||||
if isinstance(self.repository, SQLiteSearchRepository):
|
||||
await self.repository.drop_vector_tables()
|
||||
else:
|
||||
await self.repository.execute_query(
|
||||
text("DROP TABLE IF EXISTS search_vector_embeddings"), params={}
|
||||
)
|
||||
await self.repository.execute_query(
|
||||
text("DROP TABLE IF EXISTS search_vector_chunks"), params={}
|
||||
)
|
||||
await self.repository.execute_query(
|
||||
text("DROP TABLE IF EXISTS search_vector_index"), params={}
|
||||
)
|
||||
await self.init_search_index()
|
||||
|
||||
# Reindex all entities
|
||||
|
||||
@@ -310,31 +310,40 @@ async def test_write_note_accepts_directory_aliases(mcp_server, app, test_projec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_accepts_overwrite_aliases(mcp_server, app, test_project):
|
||||
"""`force`/`replace` should map to `overwrite`."""
|
||||
async def test_write_note_overwrite_canonical_via_mcp(mcp_server, app, test_project):
|
||||
"""Canonical overwrite=True must reach the handler (#818 regression)."""
|
||||
async with Client(mcp_server) as client:
|
||||
# First create
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Overwrite Alias Note",
|
||||
"title": "Overwrite Canonical Note",
|
||||
"directory": "overwrite-test",
|
||||
"content": "v1",
|
||||
},
|
||||
)
|
||||
# Overwrite using `force` alias
|
||||
blocked = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Overwrite Canonical Note",
|
||||
"directory": "overwrite-test",
|
||||
"content": "v2",
|
||||
},
|
||||
)
|
||||
assert "# Error: Note already exists" in blocked.content[0].text
|
||||
|
||||
result = await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Overwrite Alias Note",
|
||||
"title": "Overwrite Canonical Note",
|
||||
"directory": "overwrite-test",
|
||||
"content": "v2",
|
||||
"force": True, # alias for overwrite
|
||||
"overwrite": True,
|
||||
},
|
||||
)
|
||||
assert "Updated note" in result.content[0].text or "Created note" in result.content[0].text
|
||||
assert "# Updated note" in result.content[0].text
|
||||
|
||||
|
||||
# --- move_note aliases ---
|
||||
@@ -538,3 +547,19 @@ async def test_aliases_not_advertised_in_schema(mcp_server, app):
|
||||
assert canonical in props, f"{tool_name}: canonical '{canonical}' missing"
|
||||
for alias in must_not_have:
|
||||
assert alias not in props, f"{tool_name}: alias '{alias}' leaked into schema"
|
||||
|
||||
# #818: AliasChoices on optional bool broke external-client JSON schema (null-only).
|
||||
overwrite_schema = tools["write_note"].inputSchema["properties"]["overwrite"]
|
||||
schema_types: set[str] = set()
|
||||
if "type" in overwrite_schema:
|
||||
raw = overwrite_schema["type"]
|
||||
if isinstance(raw, str):
|
||||
schema_types.add(raw)
|
||||
else:
|
||||
schema_types.update(raw)
|
||||
for option in overwrite_schema.get("anyOf", ()):
|
||||
if "type" in option:
|
||||
schema_types.add(option["type"])
|
||||
assert "boolean" in schema_types, (
|
||||
f"write_note overwrite must expose boolean in schema, got {overwrite_schema}"
|
||||
)
|
||||
|
||||
@@ -70,8 +70,12 @@ def team_workspace() -> WorkspaceInfo:
|
||||
@pytest.fixture
|
||||
def route_workspaces(app):
|
||||
@contextmanager
|
||||
def route(*workspaces: WorkspaceInfo):
|
||||
with _workspace_routing(app, workspaces):
|
||||
def route(*workspaces: WorkspaceInfo, forward_permalink_headers: bool = True):
|
||||
with _workspace_routing(
|
||||
app,
|
||||
workspaces,
|
||||
forward_permalink_headers=forward_permalink_headers,
|
||||
):
|
||||
yield
|
||||
|
||||
return route
|
||||
@@ -117,7 +121,12 @@ def _save_permalink_config(
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _workspace_routing(app, workspaces: Iterable[WorkspaceInfo]):
|
||||
def _workspace_routing(
|
||||
app,
|
||||
workspaces: Iterable[WorkspaceInfo],
|
||||
*,
|
||||
forward_permalink_headers: bool = True,
|
||||
):
|
||||
"""Route MCP tool HTTP calls through an ASGI-backed cloud workspace seam."""
|
||||
workspace_list = tuple(workspaces)
|
||||
workspace_ids = {workspace.tenant_id for workspace in workspace_list}
|
||||
@@ -128,10 +137,11 @@ def _workspace_routing(app, workspaces: Iterable[WorkspaceInfo]):
|
||||
@asynccontextmanager
|
||||
async def factory(workspace: str | None = None):
|
||||
assert workspace is None or workspace in workspace_ids
|
||||
headers = workspace_permalink_headers() if forward_permalink_headers else {}
|
||||
async with HttpxAsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers=workspace_permalink_headers(),
|
||||
headers=headers,
|
||||
) as inner:
|
||||
yield inner
|
||||
|
||||
@@ -461,3 +471,39 @@ async def test_team_workspace_permalink_routes_to_specific_workspace(
|
||||
|
||||
workspace_read = await _read_json(mcp_server, identifier=expected_permalink)
|
||||
assert workspace_read["permalink"] == expected_permalink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_by_project_id_qualifies_permalink_when_headers_not_forwarded(
|
||||
mcp_server,
|
||||
test_project,
|
||||
app_config,
|
||||
team_workspace,
|
||||
route_workspaces,
|
||||
):
|
||||
"""MCP writes should return self-routing IDs even if the API omits slug headers."""
|
||||
_save_permalink_config(app_config, include_project=True, default_project=None)
|
||||
|
||||
title = "Project Id Workspace Permalink"
|
||||
short_permalink = "permalink-suite/project-id-workspace-permalink"
|
||||
expected_permalink = f"{team_workspace.slug}/{test_project.name}/{short_permalink}"
|
||||
|
||||
with route_workspaces(team_workspace, forward_permalink_headers=False):
|
||||
write_payload = await _call_json(
|
||||
mcp_server,
|
||||
"write_note",
|
||||
{
|
||||
"project_id": test_project.external_id,
|
||||
"title": title,
|
||||
"directory": "permalink-suite",
|
||||
"content": f"# {title}\n\nProject ID workspace body.",
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
assert write_payload["permalink"] == expected_permalink
|
||||
|
||||
workspace_read = await _read_json(
|
||||
mcp_server,
|
||||
identifier=f"memory://{write_payload['permalink']}",
|
||||
)
|
||||
assert workspace_read["title"] == title
|
||||
|
||||
@@ -12,6 +12,21 @@ from basic_memory.config import ProjectMode
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
["sync", "bisync", "check", "bisync-reset", "sync-setup"],
|
||||
)
|
||||
def test_cloud_sync_command_help_marks_personal_workspace_only(command):
|
||||
"""Cloud sync help should explain that local mirrors are Personal-only."""
|
||||
importlib.import_module("basic_memory.cli.commands.cloud.project_sync")
|
||||
|
||||
result = runner.invoke(app, ["cloud", command, "--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Personal workspace local mirror only" in result.output
|
||||
assert "Not supported for Team workspaces" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for background task done-callback error handling."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.deps.services import _log_task_failure
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_task_failure_ignores_cancelled_task():
|
||||
async def slow():
|
||||
await asyncio.sleep(10)
|
||||
|
||||
task = asyncio.create_task(slow())
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
with patch("basic_memory.deps.services.logger.exception") as mock_exc:
|
||||
_log_task_failure(task)
|
||||
mock_exc.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_task_failure_logs_real_exception():
|
||||
async def boom():
|
||||
raise ValueError("sync failed")
|
||||
|
||||
task = asyncio.create_task(boom())
|
||||
with pytest.raises(ValueError):
|
||||
await task
|
||||
|
||||
with patch("basic_memory.deps.services.logger.exception") as mock_exc:
|
||||
_log_task_failure(task)
|
||||
mock_exc.assert_called_once()
|
||||
assert "sync failed" in str(mock_exc.call_args)
|
||||
@@ -1,4 +1,5 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -16,11 +17,15 @@ from basic_memory.mcp.async_client import (
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_async_client_state(monkeypatch):
|
||||
async_client_module._client_factory = None
|
||||
async_client_module._prepared_local_asgi_databases.clear()
|
||||
async_client_module._prepared_local_asgi_database_prepare_locks.clear()
|
||||
monkeypatch.delenv("BASIC_MEMORY_FORCE_LOCAL", raising=False)
|
||||
monkeypatch.delenv("BASIC_MEMORY_FORCE_CLOUD", raising=False)
|
||||
monkeypatch.delenv("BASIC_MEMORY_EXPLICIT_ROUTING", raising=False)
|
||||
yield
|
||||
async_client_module._client_factory = None
|
||||
async_client_module._prepared_local_asgi_databases.clear()
|
||||
async_client_module._prepared_local_asgi_database_prepare_locks.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -51,6 +56,233 @@ async def test_get_client_default_uses_local_asgi_transport(config_manager):
|
||||
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_preinitializes_local_asgi_database(config_manager, monkeypatch):
|
||||
"""Local ASGI routing initializes DB state before request handling."""
|
||||
from basic_memory import db
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
|
||||
cfg = config_manager.load_config()
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
previous_engine = getattr(fastapi_app.state, "engine", None)
|
||||
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
|
||||
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
|
||||
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
engine = object()
|
||||
session_maker = object()
|
||||
calls = []
|
||||
|
||||
async def fake_get_or_create_db(db_path):
|
||||
calls.append(db_path)
|
||||
return engine, session_maker
|
||||
|
||||
monkeypatch.setattr(db, "get_or_create_db", fake_get_or_create_db)
|
||||
|
||||
try:
|
||||
async with get_client() as client:
|
||||
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
|
||||
assert calls == [cfg.database_path]
|
||||
assert fastapi_app.state.engine is engine
|
||||
assert fastapi_app.state.session_maker is session_maker
|
||||
assert not hasattr(fastapi_app.state, "engine")
|
||||
assert not hasattr(fastapi_app.state, "session_maker")
|
||||
finally:
|
||||
if previous_engine is None:
|
||||
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
|
||||
else:
|
||||
fastapi_app.state.engine = previous_engine
|
||||
if previous_session_maker is None:
|
||||
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
|
||||
else:
|
||||
fastapi_app.state.session_maker = previous_session_maker
|
||||
|
||||
|
||||
@pytest.mark.parametrize("async_override", [False, True])
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_uses_existing_local_asgi_database_override(
|
||||
config_manager,
|
||||
async_override,
|
||||
):
|
||||
"""Local ASGI routing honors FastAPI test dependency overrides."""
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
from basic_memory.deps import get_engine_factory
|
||||
|
||||
cfg = config_manager.load_config()
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
previous_overrides = dict(fastapi_app.dependency_overrides)
|
||||
previous_engine = getattr(fastapi_app.state, "engine", None)
|
||||
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
|
||||
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
|
||||
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
engine = object()
|
||||
session_maker = object()
|
||||
calls = []
|
||||
|
||||
if async_override:
|
||||
|
||||
async def override_engine_factory():
|
||||
calls.append("override")
|
||||
return engine, session_maker
|
||||
|
||||
else:
|
||||
|
||||
def override_engine_factory():
|
||||
calls.append("override")
|
||||
return engine, session_maker
|
||||
|
||||
fastapi_app.dependency_overrides[get_engine_factory] = override_engine_factory
|
||||
|
||||
try:
|
||||
async with get_client() as client:
|
||||
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
|
||||
assert calls == ["override"]
|
||||
assert fastapi_app.state.engine is engine
|
||||
assert fastapi_app.state.session_maker is session_maker
|
||||
assert not hasattr(fastapi_app.state, "engine")
|
||||
assert not hasattr(fastapi_app.state, "session_maker")
|
||||
finally:
|
||||
fastapi_app.dependency_overrides = previous_overrides
|
||||
if previous_engine is None:
|
||||
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
|
||||
else:
|
||||
fastapi_app.state.engine = previous_engine
|
||||
if previous_session_maker is None:
|
||||
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
|
||||
else:
|
||||
fastapi_app.state.session_maker = previous_session_maker
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_resolves_local_asgi_database_override_with_fastapi_di(
|
||||
config_manager,
|
||||
):
|
||||
"""Local ASGI DB pre-init honors FastAPI dependency injection and cleanup."""
|
||||
from fastapi import Request
|
||||
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
from basic_memory.deps import get_engine_factory
|
||||
|
||||
cfg = config_manager.load_config()
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
previous_overrides = dict(fastapi_app.dependency_overrides)
|
||||
previous_engine = getattr(fastapi_app.state, "engine", None)
|
||||
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
|
||||
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
|
||||
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
engine = object()
|
||||
session_maker = object()
|
||||
calls = []
|
||||
cleanup = []
|
||||
|
||||
async def override_engine_factory(
|
||||
request: Request,
|
||||
) -> AsyncIterator[tuple[object, object]]:
|
||||
calls.append(request.app)
|
||||
try:
|
||||
yield engine, session_maker
|
||||
finally:
|
||||
cleanup.append("closed")
|
||||
|
||||
fastapi_app.dependency_overrides[get_engine_factory] = override_engine_factory
|
||||
|
||||
try:
|
||||
async with get_client() as client:
|
||||
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
|
||||
assert calls == [fastapi_app]
|
||||
assert cleanup == []
|
||||
assert fastapi_app.state.engine is engine
|
||||
assert fastapi_app.state.session_maker is session_maker
|
||||
assert cleanup == ["closed"]
|
||||
assert not hasattr(fastapi_app.state, "engine")
|
||||
assert not hasattr(fastapi_app.state, "session_maker")
|
||||
finally:
|
||||
fastapi_app.dependency_overrides = previous_overrides
|
||||
if previous_engine is None:
|
||||
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
|
||||
else:
|
||||
fastapi_app.state.engine = previous_engine
|
||||
if previous_session_maker is None:
|
||||
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
|
||||
else:
|
||||
fastapi_app.state.session_maker = previous_session_maker
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_keeps_local_asgi_database_during_overlapping_contexts(
|
||||
config_manager,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Local ASGI database state stays installed until the last overlapping client exits."""
|
||||
from basic_memory import db
|
||||
from basic_memory.api.app import app as fastapi_app
|
||||
|
||||
cfg = config_manager.load_config()
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
previous_engine = getattr(fastapi_app.state, "engine", None)
|
||||
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
|
||||
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
|
||||
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
engine = object()
|
||||
session_maker = object()
|
||||
calls = []
|
||||
|
||||
async def fake_get_or_create_db(db_path):
|
||||
calls.append(db_path)
|
||||
return engine, session_maker
|
||||
|
||||
monkeypatch.setattr(db, "get_or_create_db", fake_get_or_create_db)
|
||||
|
||||
first_context = get_client()
|
||||
second_context = get_client()
|
||||
first_entered = False
|
||||
second_entered = False
|
||||
first_exited = False
|
||||
|
||||
try:
|
||||
first_client = await first_context.__aenter__()
|
||||
first_entered = True
|
||||
second_client = await second_context.__aenter__()
|
||||
second_entered = True
|
||||
|
||||
assert isinstance(first_client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
|
||||
assert isinstance(second_client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
|
||||
assert calls == [cfg.database_path]
|
||||
|
||||
await first_context.__aexit__(None, None, None)
|
||||
first_exited = True
|
||||
|
||||
assert fastapi_app.state.engine is engine
|
||||
assert fastapi_app.state.session_maker is session_maker
|
||||
|
||||
await second_context.__aexit__(None, None, None)
|
||||
second_entered = False
|
||||
|
||||
assert not hasattr(fastapi_app.state, "engine")
|
||||
assert not hasattr(fastapi_app.state, "session_maker")
|
||||
finally:
|
||||
if second_entered:
|
||||
await second_context.__aexit__(None, None, None)
|
||||
if first_entered and not first_exited:
|
||||
await first_context.__aexit__(None, None, None)
|
||||
|
||||
if previous_engine is None:
|
||||
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
|
||||
else:
|
||||
fastapi_app.state.engine = previous_engine
|
||||
if previous_session_maker is None:
|
||||
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
|
||||
else:
|
||||
fastapi_app.state.session_maker = previous_session_maker
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_explicit_cloud_uses_api_key(config_manager, monkeypatch):
|
||||
cfg = config_manager.load_config()
|
||||
|
||||
@@ -9,7 +9,8 @@ from sqlalchemy import select
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.mcp.tools import list_memory_projects, create_memory_project, delete_project
|
||||
from basic_memory.mcp.tools.project_management import _merge_projects
|
||||
from basic_memory.config import BasicMemoryConfig, ProjectEntry
|
||||
from basic_memory.mcp.tools.project_management import _merge_projects, _merge_workspace_projects
|
||||
from basic_memory.models.project import Project
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
||||
|
||||
@@ -909,6 +910,220 @@ def test_merge_projects_overlap():
|
||||
assert merged[0]["workspace_tenant_id"] == "org-456"
|
||||
|
||||
|
||||
def test_merge_workspace_projects_attaches_local_state_to_one_duplicate_workspace(tmp_path):
|
||||
"""A same-name team workspace project should stay cloud-only (#848)."""
|
||||
local_path = str(tmp_path / "main")
|
||||
local_main = _make_project("main", local_path, is_default=True)
|
||||
local_list = _make_list([local_main], default="main")
|
||||
personal_main = _make_project(
|
||||
"main",
|
||||
"/cloud/personal-main",
|
||||
id=10,
|
||||
external_id="personal-main-uuid",
|
||||
)
|
||||
team_main = _make_project(
|
||||
"main",
|
||||
"/cloud/team-main",
|
||||
id=11,
|
||||
external_id="team-main-uuid",
|
||||
)
|
||||
personal_ws = _make_workspace(
|
||||
"personal-tenant",
|
||||
"Personal",
|
||||
slug="personal",
|
||||
is_default=True,
|
||||
)
|
||||
team_ws = _make_workspace(
|
||||
"team-tenant",
|
||||
"Team",
|
||||
workspace_type="organization",
|
||||
slug="team",
|
||||
)
|
||||
workspace_index = _make_workspace_index(
|
||||
[
|
||||
(personal_ws, [personal_main]),
|
||||
(team_ws, [team_main]),
|
||||
]
|
||||
)
|
||||
config = BasicMemoryConfig(projects={"main": ProjectEntry(path=local_path)})
|
||||
|
||||
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
|
||||
|
||||
by_qualified_name = {project["qualified_name"]: project for project in merged}
|
||||
personal_project = by_qualified_name["personal/main"]
|
||||
team_project = by_qualified_name["team/main"]
|
||||
|
||||
assert personal_project["source"] == "local+cloud"
|
||||
assert personal_project["local_path"] == local_path
|
||||
assert personal_project["path"] == local_path
|
||||
assert team_project["source"] == "cloud"
|
||||
assert team_project["local_path"] is None
|
||||
assert team_project["path"] == "/cloud/team-main"
|
||||
|
||||
|
||||
def test_merge_workspace_projects_uses_configured_workspace_for_local_state(tmp_path):
|
||||
"""Per-project workspace_id should select the attached duplicate row."""
|
||||
local_path = str(tmp_path / "main")
|
||||
local_main = _make_project("main", local_path, is_default=True)
|
||||
local_list = _make_list([local_main], default="main")
|
||||
personal_main = _make_project(
|
||||
"main",
|
||||
"/cloud/personal-main",
|
||||
id=10,
|
||||
external_id="personal-main-uuid",
|
||||
)
|
||||
team_main = _make_project(
|
||||
"main",
|
||||
"/cloud/team-main",
|
||||
id=11,
|
||||
external_id="team-main-uuid",
|
||||
)
|
||||
personal_ws = _make_workspace(
|
||||
"personal-tenant",
|
||||
"Personal",
|
||||
slug="personal",
|
||||
is_default=True,
|
||||
)
|
||||
team_ws = _make_workspace(
|
||||
"team-tenant",
|
||||
"Team",
|
||||
workspace_type="organization",
|
||||
slug="team",
|
||||
)
|
||||
workspace_index = _make_workspace_index(
|
||||
[
|
||||
(personal_ws, [personal_main]),
|
||||
(team_ws, [team_main]),
|
||||
]
|
||||
)
|
||||
config = BasicMemoryConfig(
|
||||
projects={
|
||||
"main": ProjectEntry(
|
||||
path=local_path,
|
||||
workspace_id="team-tenant",
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
|
||||
|
||||
by_qualified_name = {project["qualified_name"]: project for project in merged}
|
||||
personal_project = by_qualified_name["personal/main"]
|
||||
team_project = by_qualified_name["team/main"]
|
||||
|
||||
assert personal_project["source"] == "cloud"
|
||||
assert personal_project["local_path"] is None
|
||||
assert personal_project["path"] == "/cloud/personal-main"
|
||||
assert team_project["source"] == "local+cloud"
|
||||
assert team_project["local_path"] == local_path
|
||||
assert team_project["path"] == local_path
|
||||
|
||||
|
||||
def test_merge_workspace_projects_uses_default_workspace_for_local_state(tmp_path):
|
||||
"""Global default_workspace should attach local state before cloud default fallback."""
|
||||
local_path = str(tmp_path / "main")
|
||||
local_main = _make_project("main", local_path, is_default=True)
|
||||
local_list = _make_list([local_main], default="main")
|
||||
personal_main = _make_project(
|
||||
"main",
|
||||
"/cloud/personal-main",
|
||||
id=10,
|
||||
external_id="personal-main-uuid",
|
||||
)
|
||||
team_main = _make_project(
|
||||
"main",
|
||||
"/cloud/team-main",
|
||||
id=11,
|
||||
external_id="team-main-uuid",
|
||||
)
|
||||
personal_ws = _make_workspace(
|
||||
"personal-tenant",
|
||||
"Personal",
|
||||
slug="personal",
|
||||
is_default=True,
|
||||
)
|
||||
team_ws = _make_workspace(
|
||||
"team-tenant",
|
||||
"Team",
|
||||
workspace_type="organization",
|
||||
slug="team",
|
||||
)
|
||||
workspace_index = _make_workspace_index(
|
||||
[
|
||||
(personal_ws, [personal_main]),
|
||||
(team_ws, [team_main]),
|
||||
]
|
||||
)
|
||||
config = BasicMemoryConfig(
|
||||
projects={"main": ProjectEntry(path=local_path)},
|
||||
default_workspace="team-tenant",
|
||||
)
|
||||
|
||||
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
|
||||
|
||||
by_qualified_name = {project["qualified_name"]: project for project in merged}
|
||||
personal_project = by_qualified_name["personal/main"]
|
||||
team_project = by_qualified_name["team/main"]
|
||||
|
||||
assert personal_project["source"] == "cloud"
|
||||
assert personal_project["local_path"] is None
|
||||
assert personal_project["path"] == "/cloud/personal-main"
|
||||
assert team_project["source"] == "local+cloud"
|
||||
assert team_project["local_path"] == local_path
|
||||
assert team_project["path"] == local_path
|
||||
|
||||
|
||||
def test_merge_workspace_projects_sorted_fallback_attaches_personal_workspace(tmp_path):
|
||||
"""When config has no preference and no cloud default exists, use stable priority."""
|
||||
local_path = str(tmp_path / "main")
|
||||
local_main = _make_project("main", local_path, is_default=True)
|
||||
local_list = _make_list([local_main], default="main")
|
||||
personal_main = _make_project(
|
||||
"main",
|
||||
"/cloud/personal-main",
|
||||
id=10,
|
||||
external_id="personal-main-uuid",
|
||||
)
|
||||
team_main = _make_project(
|
||||
"main",
|
||||
"/cloud/team-main",
|
||||
id=11,
|
||||
external_id="team-main-uuid",
|
||||
)
|
||||
personal_ws = _make_workspace(
|
||||
"personal-tenant",
|
||||
"Personal",
|
||||
slug="personal",
|
||||
is_default=False,
|
||||
)
|
||||
team_ws = _make_workspace(
|
||||
"team-tenant",
|
||||
"Team",
|
||||
workspace_type="organization",
|
||||
slug="team",
|
||||
)
|
||||
workspace_index = _make_workspace_index(
|
||||
[
|
||||
(team_ws, [team_main]),
|
||||
(personal_ws, [personal_main]),
|
||||
]
|
||||
)
|
||||
config = BasicMemoryConfig(projects={"main": ProjectEntry(path=local_path)})
|
||||
|
||||
merged = _merge_workspace_projects(local_list, workspace_index.entries, config=config)
|
||||
|
||||
by_qualified_name = {project["qualified_name"]: project for project in merged}
|
||||
personal_project = by_qualified_name["personal/main"]
|
||||
team_project = by_qualified_name["team/main"]
|
||||
|
||||
assert personal_project["source"] == "local+cloud"
|
||||
assert personal_project["local_path"] == local_path
|
||||
assert personal_project["path"] == local_path
|
||||
assert team_project["source"] == "cloud"
|
||||
assert team_project["local_path"] is None
|
||||
assert team_project["path"] == "/cloud/team-main"
|
||||
|
||||
|
||||
# --- Workspace passthrough tests ---
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.repository.search_repository_base import VectorSyncBatchResult
|
||||
from basic_memory.repository.semantic_errors import (
|
||||
@@ -256,6 +258,98 @@ async def test_reindex_vectors_respects_embed_opt_out(search_service, monkeypatc
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reindex_vectors_purges_sqlite_vectors_before_sync(search_service, monkeypatch):
|
||||
"""Regression for #829: stale vec0 rows must be purged before batch sync."""
|
||||
repository = _sqlite_repo(search_service)
|
||||
repository._semantic_enabled = True
|
||||
calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
search_service.entity_repository,
|
||||
"find_all",
|
||||
AsyncMock(return_value=[SimpleNamespace(id=42, entity_metadata={})]),
|
||||
)
|
||||
|
||||
async def delete_stale_vector_rows():
|
||||
calls.append("purge")
|
||||
|
||||
async def sync_entity_vectors_batch(entity_ids, progress_callback=None):
|
||||
assert entity_ids == [42]
|
||||
assert progress_callback is None
|
||||
calls.append("sync")
|
||||
return VectorSyncBatchResult(entities_total=1, entities_synced=1, entities_failed=0)
|
||||
|
||||
monkeypatch.setattr(repository, "delete_stale_vector_rows", delete_stale_vector_rows)
|
||||
monkeypatch.setattr(search_service, "sync_entity_vectors_batch", sync_entity_vectors_batch)
|
||||
|
||||
stats = await search_service.reindex_vectors()
|
||||
|
||||
assert calls == ["purge", "sync"]
|
||||
assert stats == {
|
||||
"total_entities": 1,
|
||||
"embedded": 1,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reindex_all_uses_sqlite_vec_aware_drop(search_service, monkeypatch):
|
||||
"""Full service reindex should not drop vec0 tables through a raw connection."""
|
||||
repository = _sqlite_repo(search_service)
|
||||
executed_sql: list[str] = []
|
||||
calls: list[str] = []
|
||||
|
||||
async def execute_query(query, params=None):
|
||||
executed_sql.append(str(query))
|
||||
|
||||
async def drop_vector_tables():
|
||||
calls.append("drop_vector_tables")
|
||||
|
||||
monkeypatch.setattr(repository, "execute_query", execute_query)
|
||||
monkeypatch.setattr(repository, "drop_vector_tables", drop_vector_tables)
|
||||
monkeypatch.setattr(search_service, "init_search_index", AsyncMock())
|
||||
monkeypatch.setattr(search_service.entity_repository, "find_all", AsyncMock(return_value=[]))
|
||||
|
||||
await search_service.reindex_all()
|
||||
|
||||
assert calls == ["drop_vector_tables"]
|
||||
assert all("search_vector_embeddings" not in sql for sql in executed_sql)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_vector_tables_skips_sqlite_vec_load_for_plain_table(
|
||||
search_service,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Plain compatibility tables should not require sqlite-vec to be loaded."""
|
||||
repository = _sqlite_repo(search_service)
|
||||
await repository.drop_vector_tables()
|
||||
|
||||
async with db.scoped_session(repository.session_maker) as session:
|
||||
await session.execute(
|
||||
text("CREATE TABLE search_vector_embeddings (rowid INTEGER PRIMARY KEY)")
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def fail_if_loaded(_session):
|
||||
raise AssertionError("plain tables should not load sqlite-vec")
|
||||
|
||||
monkeypatch.setattr(repository, "_ensure_sqlite_vec_loaded", fail_if_loaded)
|
||||
|
||||
await repository.drop_vector_tables()
|
||||
|
||||
async with db.scoped_session(repository.session_maker) as session:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type = 'table' AND name = 'search_vector_embeddings'"
|
||||
)
|
||||
)
|
||||
assert result.scalar() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reindex_vectors_force_full_clears_project_vectors_before_resync(
|
||||
search_service, monkeypatch
|
||||
|
||||
Reference in New Issue
Block a user