Compare commits

...

36 Commits

Author SHA1 Message Date
phernandez caf3c14bb1 chore: update version to 0.16.0 for v0.16.0 release 2025-11-10 19:19:48 -06:00
phernandez c5d9067754 docs: Add v0.16.0 CHANGELOG entry with comprehensive release notes
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-10 19:17:29 -06:00
phernandez e0fc59ea97 style: Format upload.py for better readability
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-10 19:15:03 -06:00
Paul Hernandez 49b2adc35c fix: skip archive files during cloud upload (#420)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-11-10 19:04:49 -06:00
Paul Hernandez 1646572f69 fix: Rename write_note entity_type to note_type for clarity (#419)
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-11-10 19:02:50 -06:00
Paul Hernandez f0d7398815 fix: Quote string values in YAML frontmatter to handle special characters (#418)
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-11-10 18:13:48 -06:00
Paul Hernandez 581b7b17c6 fix: Add explicit type annotations to MCP tool parameters (#394)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-11-10 17:02:17 -06:00
Paul Hernandez d775f7bab9 fix: Simplify search_notes schema by removing Optional wrappers (#395)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-11-10 16:30:59 -06:00
Drew Cain fc01f6abaf fix: Replace Unicode arrows with ASCII for Windows compatibility (#414) 2025-11-10 16:30:41 -06:00
Paul Hernandez 4614fd09d5 fix: Handle dict objects in write_resource endpoint (#415)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-11-10 16:30:27 -06:00
phernandez 0d4ad7bbf0 remove v0.15.0 info from assistant-guide
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-10 16:25:49 -06:00
jope-bm 7ccec7eba2 feat: Add run_in_background parameter to sync endpoint with tests (#417)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-07 09:04:43 -07:00
Paul Hernandez 021af74545 fix: Strip duplicate headers in edit_note replace_section (#396)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Drew Cain <groksrc@users.noreply.github.com>
2025-11-02 14:20:11 -06:00
phernandez 2ad0ee9d5d fix: Use force_full=true for database sync after project sync/bisync
After rclone synchronizes files between local and cloud storage, the
database needs to perform a full scan to ensure it captures all changes.
Previously, incremental sync (watermark optimization) could miss files
that were changed remotely.

Changes:
- project sync command now calls /project/sync?force_full=true
- project bisync command now calls /project/sync?force_full=true
- Ensures complete database refresh after file synchronization

This guarantees the database is fully in sync with the filesystem
after any rclone sync or bisync operation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-11-02 13:26:08 -06:00
Brandon Mayes c9946ecf1e fix: Various rclone fixes for cloud sync on Windows (#410)
Signed-off-by: Brandon Mayes <5610870+bdmayes@users.noreply.github.com>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: phernandez <paul@basicmachines.co>
2025-11-02 11:26:57 -06:00
Drew Cain 0ba6f219f1 fix: Windows CLI Unicode encoding errors (#411)
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Claude <noreply@anthropic.com>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2025-11-02 10:20:48 -06:00
Paul Hernandez 0b3272ae6e feat: SPEC-20 Simplified Project-Scoped Rclone Sync (#405)
Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-02 09:35:26 -06:00
Paul Hernandez a7d7cc5ee6 fix: Normalize YAML frontmatter types to prevent AttributeError (#236) (#402)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-27 09:19:50 -05:00
Paul Hernandez a7e696b039 Add free trial information to README
Added information about a 7-day free trial.

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2025-10-24 10:05:11 -05:00
Paul Hernandez 8aaddb6d45 Add free trial information to README
Added information about a 7-day free trial to the README.

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2025-10-24 10:04:41 -05:00
Paul Hernandez d7565312fc Announce Basic Memory Cloud launch in README
Added a section announcing the launch of Basic Memory Cloud with details on cross-device support and early supporter pricing.

Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
2025-10-24 09:53:14 -05:00
Paul Hernandez c7e6eab02f feat: Add delete_notes parameter to remove project endpoint (#391)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-21 14:09:20 -05:00
Paul Hernandez bb8da31472 fix: Handle null, empty, and string 'None' title in markdown frontmatter (#387) (#389)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-10-21 09:29:19 -05:00
Paul Hernandez e78345ff25 feat: Streaming Foundation & Async I/O Consolidation (SPEC-19) (#384)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-21 09:03:59 -05:00
Paul Hernandez 32236cd247 fix: Handle YAML parsing errors gracefully in update_frontmatter (#378) (#379)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-16 20:17:58 -05:00
Paul Hernandez 4fd6d0c648 fix: Optimize sync memory usage to prevent OOM on large projects (#380)
Signed-off-by: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-10-16 20:17:34 -05:00
Paul Hernandez e6c8e3662c fix: preserve mtime webdav upload 376 (#377)
Signed-off-by: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-16 17:18:10 -05:00
Paul Hernandez 449b62d947 fix: Prevent deleted projects from being recreated by background sync (#193) (#370)
Signed-off-by: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-10-16 15:16:24 -05:00
Paul Hernandez b7497d7484 fix: Use filesystem timestamps for entity sync instead of database operation time (#138) (#369)
Signed-off-by: Claude <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2025-10-16 14:21:27 -05:00
Paul Hernandez d1431bdb1b fix: Handle YAML parsing errors and missing entity_type in markdown files (#368)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-16 13:18:58 -05:00
Paul Hernandez 171bef717f fix: Resolve UNIQUE constraint violation in entity upsert with observations (#187) (#367)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-16 12:30:56 -05:00
Paul Hernandez 729a5a3b8d fix: Terminate sync immediately when project is deleted (#366)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-16 11:07:12 -05:00
Paul Hernandez 434cdf24dd feat: Add circuit breaker for file sync failures (#364)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-16 09:47:48 -05:00
Paul Hernandez 7f9c1a97a4 feat: Add --verbose and --no-gitignore options to cloud upload (#362)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-15 20:03:36 -05:00
Paul Hernandez 53fb13b054 fix: Make project creation endpoint idempotent (#357)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-15 19:27:41 -05:00
Paul Hernandez bd6c8348b8 fix: Handle None text values in Claude conversations importer (#353)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-10-15 16:08:57 -05:00
103 changed files with 11946 additions and 5374 deletions
+4 -1
View File
@@ -71,9 +71,12 @@ jobs:
- [ ] Proper error handling and logging
- [ ] Performance considerations addressed
- [ ] No sensitive data in logs or commits
## Compatability
- [ ] File path comparisons must be windows compatible
- [ ] Avoid using emojis and unicode characters in console and log output
Read the CLAUDE.md file for detailed project context. For each checklist item, verify if it's satisfied and comment on any that need attention. Use inline comments for specific code issues and post a summary with checklist results.
# Allow broader tool access for thorough code review
claude_args: '--allowed-tools "Bash(gh pr:*),Bash(gh issue:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Read,Grep,Glob"'
+234
View File
@@ -1,5 +1,239 @@
# CHANGELOG
## v0.16.0 (2025-11-10)
### Features
- **#417**: Add run_in_background parameter to sync endpoint
([`7ccec7e`](https://github.com/basicmachines-co/basic-memory/commit/7ccec7e))
- New `run_in_background` parameter for async sync operations
- Improved API flexibility for long-running sync tasks
- Comprehensive test coverage for background sync behavior
- **#405**: SPEC-20 Simplified Project-Scoped Rclone Sync
([`0b3272a`](https://github.com/basicmachines-co/basic-memory/commit/0b3272a))
- Simplified and more reliable cloud synchronization
- Project-scoped rclone configuration
- Better error handling and status reporting
- **#384**: Streaming Foundation & Async I/O Consolidation (SPEC-19)
([`e78345f`](https://github.com/basicmachines-co/basic-memory/commit/e78345f))
- Foundation for streaming support in future releases
- Consolidated async I/O patterns across codebase
- Improved performance and resource management
- **#364**: Add circuit breaker for file sync failures
([`434cdf2`](https://github.com/basicmachines-co/basic-memory/commit/434cdf2))
- Prevents cascading failures during sync operations
- Automatic recovery from transient errors
- Better resilience in cloud sync scenarios
- **#362**: Add --verbose and --no-gitignore options to cloud upload
([`7f9c1a9`](https://github.com/basicmachines-co/basic-memory/commit/7f9c1a9))
- Enhanced upload control with verbose logging
- Option to bypass gitignore filtering when needed
- Better debugging and troubleshooting capabilities
- **#391**: Add delete_notes parameter to remove project endpoint
([`c9946ec`](https://github.com/basicmachines-co/basic-memory/commit/c9946ec))
- Option to delete notes when removing projects
- Safer project cleanup workflows
- Prevents accidental data loss
### Bug Fixes
- **#420**: Skip archive files during cloud upload
([`49b2adc`](https://github.com/basicmachines-co/basic-memory/commit/49b2adc))
- Prevents uploading of zip, tar, gz and other archive files
- Reduces storage usage and upload time
- Better file filtering during cloud operations
- **#419**: Rename write_note entity_type to note_type for clarity
([`1646572`](https://github.com/basicmachines-co/basic-memory/commit/1646572))
- Clearer parameter naming in write_note tool
- Improved API consistency and documentation
- Better developer experience
- **#418**: Quote string values in YAML frontmatter to handle special characters
([`f0d7398`](https://github.com/basicmachines-co/basic-memory/commit/f0d7398))
- Fixes YAML parsing errors with special characters
- More robust frontmatter handling
- Prevents data corruption in edge cases
- **#415**: Handle dict objects in write_resource endpoint
([`4614fd0`](https://github.com/basicmachines-co/basic-memory/commit/4614fd0))
- Fixes errors when writing dictionary resources
- Better type handling in resource endpoints
- Improved API robustness
- **#414**: Replace Unicode arrows with ASCII for Windows compatibility
([`fc01f6a`](https://github.com/basicmachines-co/basic-memory/commit/fc01f6a))
- Fixes display issues on Windows terminals
- Better cross-platform compatibility
- Improved CLI user experience on Windows
- **#411**: Windows CLI Unicode encoding errors
([`0ba6f21`](https://github.com/basicmachines-co/basic-memory/commit/0ba6f21))
- Resolves Unicode encoding issues on Windows
- Better handling of international characters
- Improved Windows platform support
- **#410**: Various rclone fixes for cloud sync on Windows
([`c9946ec`](https://github.com/basicmachines-co/basic-memory/commit/c9946ec))
- Fixes cloud sync reliability on Windows
- Better path handling for Windows filesystem
- Improved rclone integration on Windows
- **#402**: Normalize YAML frontmatter types to prevent AttributeError
([`a7d7cc5`](https://github.com/basicmachines-co/basic-memory/commit/a7d7cc5))
- Fixes AttributeError when reading frontmatter
- More robust type normalization
- Better error handling in markdown parsing
- **#396**: Strip duplicate headers in edit_note replace_section
([`021af74`](https://github.com/basicmachines-co/basic-memory/commit/021af74))
- Prevents duplicate headers when replacing sections
- Cleaner note editing behavior
- Better content consistency
- **#395**: Simplify search_notes schema by removing Optional wrappers
([`d775f7b`](https://github.com/basicmachines-co/basic-memory/commit/d775f7b))
- Cleaner API schema definition
- Better type safety and validation
- Improved developer experience
- **#394**: Add explicit type annotations to MCP tool parameters
([`581b7b1`](https://github.com/basicmachines-co/basic-memory/commit/581b7b1))
- Better type safety in MCP tools
- Improved IDE support and autocomplete
- Clearer tool documentation
- **#389**: Handle null, empty, and string 'None' title in markdown frontmatter
([`bb8da31`](https://github.com/basicmachines-co/basic-memory/commit/bb8da31))
- Fixes errors with malformed frontmatter titles
- More robust title handling
- Better error recovery
- **#380**: Optimize sync memory usage to prevent OOM on large projects
([`4fd6d0c`](https://github.com/basicmachines-co/basic-memory/commit/4fd6d0c))
- Prevents out-of-memory errors on large knowledge bases
- Better memory management during sync
- Improved scalability
- **#379**: Handle YAML parsing errors gracefully in update_frontmatter
([`32236cd`](https://github.com/basicmachines-co/basic-memory/commit/32236cd))
- Better error handling for malformed YAML
- Graceful degradation instead of crashes
- Improved robustness
- **#377**: Preserve mtime on WebDAV upload
([`e6c8e36`](https://github.com/basicmachines-co/basic-memory/commit/e6c8e36))
- Maintains file modification times during upload
- Better sync accuracy
- Prevents unnecessary re-syncing
- **#370**: Prevent deleted projects from being recreated by background sync
([`449b62d`](https://github.com/basicmachines-co/basic-memory/commit/449b62d))
- Fixes race condition with project deletion
- Better lifecycle management
- Prevents unwanted project recreation
- **#369**: Use filesystem timestamps for entity sync instead of database operation time
([`b7497d7`](https://github.com/basicmachines-co/basic-memory/commit/b7497d7))
- More accurate sync detection
- Better handling of external file modifications
- Improved sync reliability
- **#368**: Handle YAML parsing errors and missing entity_type in markdown files
([`d1431bd`](https://github.com/basicmachines-co/basic-memory/commit/d1431bd))
- Better error handling for malformed markdown
- Graceful handling of missing metadata
- Improved robustness
- **#367**: Resolve UNIQUE constraint violation in entity upsert with observations
([`171bef7`](https://github.com/basicmachines-co/basic-memory/commit/171bef7))
- Fixes database constraint errors during sync
- Better handling of duplicate observations
- Improved data integrity
- **#366**: Terminate sync immediately when project is deleted
([`729a5a3`](https://github.com/basicmachines-co/basic-memory/commit/729a5a3))
- Faster project deletion
- Better resource cleanup
- Improved user experience
- **#357**: Make project creation endpoint idempotent
([`53fb13b`](https://github.com/basicmachines-co/basic-memory/commit/53fb13b))
- Prevents errors when creating existing projects
- Better API reliability
- Improved cloud integration
- **#353**: Handle None text values in Claude conversations importer
([`bd6c834`](https://github.com/basicmachines-co/basic-memory/commit/bd6c834))
- Fixes import errors with empty messages
- Better error handling in importers
- Improved data migration
### Performance Improvements
- Force full database sync after project sync/bisync operations
([`2ad0ee9`](https://github.com/basicmachines-co/basic-memory/commit/2ad0ee9))
- Ensures database consistency after cloud operations
- Better sync reliability
- Improved data integrity
### Documentation
- Add free trial information to README
([`a7d7cc5`](https://github.com/basicmachines-co/basic-memory/commit/a7d7cc5), [`8aaddb6`](https://github.com/basicmachines-co/basic-memory/commit/8aaddb6))
- Updated README with Basic Memory Cloud trial info
- Better onboarding experience
- Clearer pricing information
- Announce Basic Memory Cloud launch in README
([`d756531`](https://github.com/basicmachines-co/basic-memory/commit/d756531))
- Official cloud service announcement
- Updated documentation for cloud features
- Improved product positioning
### Migration Guide
No manual migration required. Upgrade with:
```bash
# Update via uv
uv tool upgrade basic-memory
# Or install fresh
uv tool install basic-memory
```
**What's New in v0.16.0:**
- Streaming foundation and consolidated async I/O (SPEC-19)
- Simplified project-scoped rclone sync (SPEC-20)
- Circuit breaker for sync failure resilience
- Enhanced Windows platform support
- Improved cloud upload with verbose and gitignore options
- Better error handling across YAML parsing and frontmatter
- Memory optimization for large projects
- Archive file filtering during upload
**Breaking Changes:**
- `write_note` parameter renamed: `entity_type``note_type` for clarity
### Installation
```bash
# Latest stable release
uv tool install basic-memory
# Update existing installation
uv tool upgrade basic-memory
# Docker
docker pull ghcr.io/basicmachines-co/basic-memory:v0.16.0
```
## v0.15.2 (2025-10-14)
### Features
+11 -1
View File
@@ -7,6 +7,16 @@
![](https://badge.mcpx.dev?type=dev 'MCP Dev')
[![smithery badge](https://smithery.ai/badge/@basicmachines-co/basic-memory)](https://smithery.ai/server/@basicmachines-co/basic-memory)
## 🚀 Basic Memory Cloud is Live!
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile - seamlessly synced across all your AI tools (Claude, ChatGPT, Gemini, Claude Code, and Codex)
- **Early Supporter Pricing:** Early users get 25% off forever.
The open source project continues as always. Cloud just makes it work everywhere.
[Sign up now →](https://basicmemory.com/beta)
with a 7 day free trial
# Basic Memory
Basic Memory lets you build persistent knowledge through natural conversations with Large Language Models (LLMs) like
@@ -440,4 +450,4 @@ and submitting PRs.
</picture>
</a>
Built with ♥️ by Basic Machines
Built with ♥️ by Basic Machines
+7 -26
View File
@@ -77,25 +77,6 @@ SQLite Database (Index)
3. Files are parsed and indexed in SQLite
4. MCP server exposes indexed data to AI
5. AI can query, traverse, and update knowledge graph
### Version 0.15.0 Changes
**Breaking Change: Stateless Architecture**
- All MCP tools now require explicit `project` parameter
- No implicit project context carried between calls
- Exception: `default_project_mode` config option enables fallback
**Three-Tier Project Resolution**:
1. CLI constraint: `--project name` flag (highest priority)
2. Explicit parameter: `project="name"` in tool calls
3. Default mode: `default_project_mode=true` in config (fallback)
**Why This Matters**:
- More predictable behavior across sessions
- Explicit project selection prevents errors
- Multi-project workflows more reliable
- Single-project users can enable default mode for convenience
---
## Project Management
@@ -576,7 +557,7 @@ Adopt GraphQL instead of REST for our API layer.
""",
folder="decisions",
tags=["decision", "api", "graphql"],
entity_type="decision",
note_type="decision",
project="main"
)
```
@@ -613,7 +594,7 @@ await write_note(
""",
folder="meetings",
tags=["meeting", "api", "team"],
entity_type="meeting",
note_type="meeting",
project="main"
)
```
@@ -665,7 +646,7 @@ Specification for user authentication system using JWT tokens.
""",
folder="specs",
tags=["spec", "auth", "security"],
entity_type="spec",
note_type="spec",
project="main"
)
```
@@ -1631,7 +1612,7 @@ await write_note(
{related_entities}
""",
folder="decisions",
entity_type="decision",
note_type="decision",
project="main"
)
```
@@ -2706,14 +2687,14 @@ await write_note(
### Content Management
**write_note(title, content, folder, tags, entity_type, project)**
**write_note(title, content, folder, tags, note_type, project)**
- Create or update markdown notes
- Parameters:
- `title` (required): Note title
- `content` (required): Markdown content
- `folder` (required): Destination folder
- `tags` (optional): List of tags
- `entity_type` (optional): Entity type (note, person, meeting, etc.)
- `note_type` (optional): Type of note (stored in frontmatter). Can be "note", "person", "meeting", "guide", etc.
- `project` (required unless default_project_mode): Target project
- Returns: Created/updated entity with permalink
- Example:
@@ -2723,7 +2704,7 @@ await write_note(
content="# API Design\n...",
folder="specs",
tags=["api", "design"],
entity_type="spec",
note_type="spec",
project="main"
)
```
+565 -569
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
# Install dependencies
install:
pip install -e ".[dev]"
uv pip install -e ".[dev]"
uv sync
@echo ""
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
+1
View File
@@ -35,6 +35,7 @@ dependencies = [
"python-dotenv>=1.1.0",
"pytest-aio>=1.9.0",
"aiofiles>=24.1.0", # Async file I/O
"logfire>=0.73.0", # Optional observability (disabled by default via config)
]
@@ -0,0 +1,569 @@
---
title: 'SPEC-10: Unified Deployment Workflow and Event Tracking'
type: spec
permalink: specs/spec-10-unified-deployment-workflow-event-tracking
tags:
- workflow
- deployment
- event-sourcing
- architecture
- simplification
---
# SPEC-10: Unified Deployment Workflow and Event Tracking
## Why
We replaced a complex multi-workflow system with DBOS orchestration that was proving to be more trouble than it was worth. The previous architecture had four separate workflows (`tenant_provisioning`, `tenant_update`, `tenant_deployment`, `tenant_undeploy`) with overlapping logic, complex state management, and fragmented event tracking. DBOS added unnecessary complexity without providing sufficient value, leading to harder debugging and maintenance.
**Problems Solved:**
- **Framework Complexity**: DBOS configuration overhead and fighting framework limitations
- **Code Duplication**: Multiple workflows implementing similar operations with duplicate logic
- **Poor Observability**: Fragmented event tracking across workflow boundaries
- **Maintenance Overhead**: Complex orchestration for fundamentally simple operations
- **Debugging Difficulty**: Framework abstractions hiding simple Python stack traces
## What
This spec documents the architectural simplification that consolidates tenant lifecycle management into a unified system with comprehensive event tracking.
**Affected Areas:**
- Tenant deployment workflows (provisioning, updates, undeploying)
- Event sourcing and workflow tracking infrastructure
- API endpoints for tenant operations
- Database schema for workflow and event correlation
- Integration testing for tenant lifecycle operations
**Key Changes:**
- **Removed DBOS entirely** - eliminated framework dependency and complexity
- **Consolidated 4 workflows → 2 unified deployment workflows (deploy/undeploy)**
- **Added workflow tracking system** with complete event correlation
- **Simplified API surface** - single `/deploy` endpoint handles all scenarios
- **Enhanced observability** through event sourcing with workflow grouping
## How (High Level)
### Architectural Philosophy
**Embrace simplicity over framework complexity** - use well-structured Python with proper database design instead of complex orchestration frameworks.
### Core Components
#### 1. Unified Deployment Workflow
```python
class TenantDeploymentWorkflow:
async def deploy_tenant_workflow(self, tenant_id: str, workflow_id: UUID, image_tag: str = None):
# Single workflow handles both initial provisioning AND updates
# Each step is idempotent and handles its own error recovery
# Database transactions provide the durability we need
await self.start_deployment_step(workflow_id, tenant_uuid, image_tag)
await self.create_fly_app_step(workflow_id, tenant_uuid)
await self.create_bucket_step(workflow_id, tenant_uuid)
await self.deploy_machine_step(workflow_id, tenant_uuid, image_tag)
await self.complete_deployment_step(workflow_id, tenant_uuid, image_tag, deployment_time)
```
**Key Benefits:**
- **Handles both provisioning and updates** in single workflow
- **Idempotent operations** - safe to retry any step
- **Clean error handling** via simple Python exceptions
- **Resumable** - can restart from any failed step
#### 2. Workflow Tracking System
**Database Schema:**
```sql
CREATE TABLE workflow (
id UUID PRIMARY KEY,
workflow_type VARCHAR(50) NOT NULL, -- 'tenant_deployment', 'tenant_undeploy'
tenant_id UUID REFERENCES tenant(id),
status VARCHAR(20) DEFAULT 'running', -- 'running', 'completed', 'failed'
workflow_metadata JSONB DEFAULT '{}' -- image_tag, etc.
);
ALTER TABLE event ADD COLUMN workflow_id UUID REFERENCES workflow(id);
```
**Event Correlation:**
- Every workflow operation generates events tagged with `workflow_id`
- Complete audit trail from workflow start to completion
- Events grouped by workflow for easy reconstruction of operations
#### 3. Parameter Standardization
All workflow methods follow consistent signature pattern:
```python
async def method_name(self, session: AsyncSession, workflow_id: UUID | None, tenant_id: UUID, ...)
```
**Benefits:**
- **Consistent event tagging** - all events properly correlated
- **Clear method contracts** - workflow_id always first parameter
- **Type safety** - proper UUID handling throughout
### Implementation Strategy
#### Phase 1: Workflow Consolidation ✅ COMPLETED
- [x] **Remove DBOS dependency** - eliminated dbos_config.py and all DBOS imports
- [x] **Create unified TenantDeploymentWorkflow** - handles both provisioning and updates
- [x] **Remove legacy workflows** - deleted tenant_provisioning.py, tenant_update.py
- [x] **Simplify API endpoints** - consolidated to single `/deploy` endpoint
- [x] **Update integration tests** - comprehensive edge case testing
#### Phase 2: Workflow Tracking System ✅ COMPLETED
- [x] **Database migration** - added workflow table and event.workflow_id foreign key
- [x] **Workflow repository** - CRUD operations for workflow records
- [x] **Event correlation** - all workflow events tagged with workflow_id
- [x] **Comprehensive testing** - workflow lifecycle and event grouping tests
#### Phase 3: Parameter Standardization ✅ COMPLETED
- [x] **Standardize method signatures** - workflow_id as first parameter pattern
- [x] **Fix event tagging** - ensure all workflow events properly correlated
- [x] **Update service methods** - consistent parameter order across tenant_service
- [x] **Integration test validation** - verify complete event sequences
### Architectural Benefits
#### Code Simplification
- **39 files changed**: 2,247 additions, 3,256 deletions (net -1,009 lines)
- **Eliminated framework complexity** - no more DBOS configuration or abstractions
- **Consolidated logic** - single deployment workflow vs 4 separate workflows
- **Cleaner API surface** - unified endpoint vs multiple workflow-specific endpoints
#### Enhanced Observability
- **Complete event correlation** - every workflow event tagged with workflow_id
- **Audit trail reconstruction** - can trace entire tenant lifecycle through events
- **Workflow status tracking** - running/completed/failed states in database
- **Comprehensive testing** - edge cases covered with real infrastructure
#### Operational Benefits
- **Simpler debugging** - plain Python stack traces vs framework abstractions
- **Reduced dependencies** - one less complex framework to maintain
- **Better error handling** - explicit exception handling vs framework magic
- **Easier maintenance** - straightforward Python code vs orchestration complexity
## How to Evaluate
### Success Criteria
#### Functional Completeness ✅ VERIFIED
- [x] **Unified deployment workflow** handles both initial provisioning and updates
- [x] **Undeploy workflow** properly integrated with event tracking
- [x] **All operations idempotent** - safe to retry any step without duplication
- [x] **Complete tenant lifecycle** - provision → active → update → undeploy
#### Event Tracking and Correlation ✅ VERIFIED
- [x] **All workflow events tagged** with proper workflow_id
- [x] **Event sequence verification** - tests assert exact event order and content
- [x] **Workflow grouping** - events can be queried by workflow_id for complete audit trail
- [x] **Cross-workflow isolation** - deployment vs undeploy events properly separated
#### Database Schema and Performance ✅ VERIFIED
- [x] **Migration applied** - workflow table and event.workflow_id column created
- [x] **Proper indexing** - performance optimized queries on workflow_type, tenant_id, status
- [x] **Foreign key constraints** - referential integrity between workflows and events
- [x] **Database triggers** - updated_at timestamp automation
#### Test Coverage ✅ COMPREHENSIVE
- [x] **Unit tests**: 4 workflow tracking tests covering lifecycle and event grouping
- [x] **Integration tests**: Real infrastructure testing with Fly.io resources
- [x] **Edge case coverage**: Failed deployments, partial state recovery, resource conflicts
- [x] **Event sequence verification**: Exact event order and content validation
### Testing Procedure
#### Unit Test Validation ✅ PASSING
```bash
cd apps/cloud && pytest tests/test_workflow_tracking.py -v
# 4/4 tests passing - workflow lifecycle and event grouping
```
#### Integration Test Validation ✅ PASSING
```bash
cd apps/cloud && pytest tests/integration/test_tenant_workflow_deployment_integration.py -v
cd apps/cloud && pytest tests/integration/test_tenant_workflow_undeploy_integration.py -v
# Comprehensive real infrastructure testing with actual Fly.io resources
# Tests provision → deploy → update → undeploy → cleanup cycles
```
### Performance Metrics
#### Code Metrics ✅ ACHIEVED
- **Net code reduction**: -1,009 lines (3,256 deletions, 2,247 additions)
- **Workflow consolidation**: 4 workflows → 1 unified deployment workflow
- **Dependency reduction**: Removed DBOS framework dependency entirely
- **API simplification**: Multiple endpoints → single `/deploy` endpoint
#### Operational Metrics ✅ VERIFIED
- **Event correlation**: 100% of workflow events properly tagged with workflow_id
- **Audit trail completeness**: Full tenant lifecycle traceable through event sequences
- **Error handling**: Clean Python exceptions vs framework abstractions
- **Debugging simplicity**: Direct stack traces vs orchestration complexity
### Implementation Status: ✅ COMPLETE
All phases completed successfully with comprehensive testing and verification:
**Phase 1 - Workflow Consolidation**: ✅ COMPLETE
- Removed DBOS dependency and consolidated workflows
- Unified deployment workflow handles all scenarios
- Comprehensive integration testing with real infrastructure
**Phase 2 - Workflow Tracking**: ✅ COMPLETE
- Database schema implemented with proper indexing
- Event correlation system fully functional
- Complete audit trail capability verified
**Phase 3 - Parameter Standardization**: ✅ COMPLETE
- Consistent method signatures across all workflow methods
- All events properly tagged with workflow_id
- Type safety verified across entire codebase
**Phase 4 - Asynchronous Job Queuing**:
**Goal**: Transform synchronous deployment workflows into background jobs for better user experience and system reliability.
**Current Problem**:
- Deployment API calls are synchronous - users wait for entire tenant provisioning (30-60 seconds)
- No retry mechanism for failed operations
- HTTP timeouts on long-running deployments
- Poor user experience during infrastructure provisioning
**Solution**: Redis-backed job queue with arq for reliable background processing
#### Architecture Overview
```python
# API Layer: Return immediately with job tracking
@router.post("/{tenant_id}/deploy")
async def deploy_tenant(tenant_id: UUID):
# Create workflow record in Postgres
workflow = await workflow_repo.create_workflow("tenant_deployment", tenant_id)
# Enqueue job in Redis
job = await arq_pool.enqueue_job('deploy_tenant_task', tenant_id, workflow.id)
# Return job ID immediately
return {"job_id": job.job_id, "workflow_id": workflow.id, "status": "queued"}
# Background Worker: Process via existing unified workflow
async def deploy_tenant_task(ctx, tenant_id: str, workflow_id: str):
# Existing workflow logic - zero changes needed!
await workflow_manager.deploy_tenant(UUID(tenant_id), workflow_id=UUID(workflow_id))
```
#### Implementation Tasks
**Phase 4.1: Core Job Queue Setup** ✅ COMPLETED
- [x] **Add arq dependency** - integrated Redis job queue with existing infrastructure
- [x] **Create job definitions** - wrapped existing deployment/undeploy workflows as arq tasks
- [x] **Update API endpoints** - updated provisioning endpoints to return job IDs instead of waiting for completion
- [x] **JobQueueService implementation** - service layer for job enqueueing and status tracking
- [x] **Job status tracking** - integrated with existing workflow table for status updates
- [x] **Comprehensive testing** - 18 tests covering positive, negative, and edge cases
**Phase 4.2: Background Worker Implementation** ✅ COMPLETED
- [x] **Job status API** - GET /jobs/{job_id}/status endpoint integrated with JobQueueService
- [x] **Background worker process** - arq worker to process queued jobs with proper settings and Redis configuration
- [x] **Worker settings and configuration** - WorkerSettings class with proper timeouts, max jobs, and error handling
- [x] **Fix API endpoints** - updated job status API to use JobQueueService instead of direct Redis access
- [x] **Integration testing** - comprehensive end-to-end testing with real ARQ workers and Fly.io infrastructure
- [x] **Worker entry points** - dual-purpose entrypoint.sh script and __main__.py module support for both API and worker processes
- [x] **Test fixture updates** - fixed all API and service test fixtures to work with job queue dependencies
- [x] **AsyncIO event loop fixes** - resolved event loop issues in integration tests for subprocess worker compatibility
- [x] **Complete test coverage** - all 46 tests passing across unit, integration, and API test suites
- [x] **Type safety verification** - 0 type checking errors across entire ARQ job queue implementation
#### Phase 4.2 Implementation Summary ✅ COMPLETE
**Core ARQ Job Queue System:**
- **JobQueueService** - Centralized service for job enqueueing, status tracking, and Redis pool management
- **deployment_jobs.py** - ARQ job functions that wrap existing deployment/undeploy workflows
- **Worker Settings** - Production-ready ARQ configuration with proper timeouts and error handling
- **Dual-Process Architecture** - Single Docker image with entrypoint.sh supporting both API and worker modes
**Key Files Added:**
- `apps/cloud/src/basic_memory_cloud/jobs/` - Complete job queue implementation (7 files)
- `apps/cloud/entrypoint.sh` - Dual-purpose Docker container entry point
- `apps/cloud/tests/integration/test_worker_integration.py` - Real infrastructure integration tests
- `apps/cloud/src/basic_memory_cloud/schemas/job_responses.py` - API response schemas
**API Integration:**
- Provisioning endpoints return job IDs immediately instead of blocking for 60+ seconds
- Job status API endpoints for real-time monitoring of deployment progress
- Proper error handling and job failure scenarios with detailed error messages
**Testing Achievement:**
- **46 total tests passing** across all test suites (unit, integration, API, services)
- **Real infrastructure testing** - ARQ workers process actual Fly.io deployments
- **Event loop safety** - Fixed asyncio issues for subprocess worker compatibility
- **Test fixture updates** - All fixtures properly support job queue dependencies
- **Type checking** - 0 errors across entire codebase
**Technical Metrics:**
- **38 files changed** - +1,736 insertions, -334 deletions
- **Integration test runtime** - ~18 seconds with real ARQ workers and Fly.io verification
- **Event loop isolation** - Proper async session management for subprocess compatibility
- **Redis integration** - Production-ready Redis configuration with connection pooling
**Phase 4.3: Production Hardening** ✅ COMPLETED
- [x] **Configure Upstash Redis** - production Redis setup on Fly.io
- [x] **Retry logic for external APIs** - exponential backoff for flaky Tigris IAM operations
- [x] **Monitoring and observability** - comprehensive Redis queue monitoring with CLI tools
- [x] **Error handling improvements** - graceful handling of expected API errors with appropriate log levels
- [x] **CLI tooling enhancements** - bulk update commands for CI/CD automation
- [x] **Documentation improvements** - comprehensive monitoring guide with Redis patterns
- [x] **Job uniqueness** - ARQ-based duplicate prevention for tenant operations
- [ ] **Worker scaling** - multiple arq workers for parallel job processing
- [ ] **Job persistence** - ensure jobs survive Redis/worker restarts
- [ ] **Error alerting** - notifications for failed deployment jobs
**Phase 4.4: Advanced Features** (Future)
- [ ] **Job scheduling** - deploy tenants at specific times
- [ ] **Priority queues** - urgent deployments processed first
- [ ] **Batch operations** - bulk tenant deployments
- [ ] **Job dependencies** - deployment → configuration → activation chains
#### Benefits Achieved ✅ REALIZED
**User Experience Improvements:**
- **Immediate API responses** - users get job ID instantly vs waiting 60+ seconds for deployment completion
- **Real-time job tracking** - status API provides live updates on deployment progress
- **Better error visibility** - detailed error messages and job failure tracking
- **CI/CD automation ready** - bulk update commands for automated tenant deployments
**System Reliability:**
- **Redis persistence** - jobs survive Redis/worker restarts with proper queue durability
- **Idempotent job processing** - jobs can be safely retried without side effects
- **Event loop isolation** - worker processes operate independently from API server
- **Retry resilience** - exponential backoff for flaky external API calls (3 attempts, 1s/2s delays)
- **Graceful error handling** - expected API errors logged at INFO level, unexpected at ERROR level
- **Job uniqueness** - prevent duplicate tenant operations with ARQ's built-in uniqueness feature
**Operational Benefits:**
- **Horizontal scaling ready** - architecture supports adding more workers for parallel processing
- **Comprehensive testing** - real infrastructure integration tests ensure production reliability
- **Type safety** - full type checking prevents runtime errors in job processing
- **Clean separation** - API and worker processes use same codebase with different entry points
- **Queue monitoring** - Redis CLI integration for real-time queue activity monitoring
- **Comprehensive documentation** - detailed monitoring guide with Redis pattern explanations
**Development Benefits:**
- **Zero workflow changes** - existing deployment/undeploy workflows work unchanged as background jobs
- **Async/await native** - modern Python asyncio patterns throughout the implementation
- **Event correlation preserved** - all existing workflow tracking and event sourcing continues to work
- **Enhanced CLI tooling** - unified tenant commands with proper endpoint routing
- **Database integrity** - proper foreign key constraint handling in tenant deletion
#### Infrastructure Requirements
- **Local**: Redis via docker-compose (already exists) ✅
- **Production**: Upstash Redis on Fly.io (already configured) ✅
- **Workers**: arq worker processes (new deployment target)
- **Monitoring**: Job status dashboard (simple web interface)
#### API Evolution
```python
# Before: Synchronous (blocks for 60+ seconds)
POST /tenant/{id}/deploy {status: "active", machine_id: "..."}
# After: Asynchronous (returns immediately)
POST /tenant/{id}/deploy {job_id: "uuid", workflow_id: "uuid", status: "queued"}
GET /jobs/{job_id}/status {status: "running", progress: "deploying_machine", workflow_id: "uuid"}
GET /workflows/{workflow_id}/events [...] # Existing event tracking works unchanged
```
**Technology Choice**: **arq (Redis)** over pgqueuer
- **Existing Redis infrastructure** - Upstash + docker-compose already configured
- **Better ecosystem** - monitoring tools, documentation, community
- **Made by pydantic team** - aligns with existing Python stack
- **Hybrid approach** - Redis for queue operations + Postgres for workflow state
#### Job Uniqueness Implementation
**Problem**: Multiple concurrent deployment requests for the same tenant could create duplicate jobs, wasting resources and potentially causing conflicts.
**Solution**: Leverage ARQ's built-in job uniqueness feature using predictable job IDs:
```python
# JobQueueService implementation
async def enqueue_deploy_job(self, tenant_id: UUID, image_tag: str | None = None) -> str:
unique_job_id = f"deploy-{tenant_id}"
job = await self.redis_pool.enqueue_job(
"deploy_tenant_job",
str(tenant_id),
image_tag,
_job_id=unique_job_id, # ARQ prevents duplicates
)
if job is None:
# Job already exists - return existing job ID
return unique_job_id
else:
# New job created - return ARQ job ID
return job.job_id
```
**Key Features:**
- **Predictable Job IDs**: `deploy-{tenant_id}`, `undeploy-{tenant_id}`
- **Duplicate Prevention**: ARQ returns `None` for duplicate job IDs
- **Graceful Handling**: Return existing job ID instead of raising errors
- **Idempotent Operations**: Safe to retry deployment requests
- **Clear Logging**: Distinguish "Enqueued new" vs "Found existing" jobs
**Benefits:**
- Prevents resource waste from duplicate deployments
- Eliminates race conditions from concurrent requests
- Makes job monitoring more predictable with consistent IDs
- Provides natural deduplication without complex locking mechanisms
## Notes
### Design Philosophy Lessons
- **Simplicity beats framework magic** - removing DBOS made the system more reliable and debuggable
- **Event sourcing > complex orchestration** - database-backed event tracking provides better observability than framework abstractions
- **Idempotent operations > resumable workflows** - each step handling its own retry logic is simpler than framework-managed resumability
- **Explicit error handling > framework exception handling** - Python exceptions are clearer than orchestration framework error states
### Future Considerations
- **Monitoring integration** - workflow tracking events could feed into observability systems
- **Performance optimization** - event querying patterns may benefit from additional indexing
- **Audit compliance** - complete event trail supports regulatory requirements
- **Operational dashboards** - workflow status could drive tenant health monitoring
### Related Specifications
- **SPEC-8**: TigrisFS Integration - bucket provisioning integrated with deployment workflow
- **SPEC-1**: Specification-Driven Development Process - this spec follows the established format
## Observations
- [architecture] Removing framework complexity led to more maintainable system #simplification
- [workflow] Single unified deployment workflow handles both provisioning and updates #consolidation
- [observability] Event sourcing with workflow correlation provides complete audit trail #event-tracking
- [database] Foreign key relationships between workflows and events enable powerful queries #schema-design
- [testing] Integration tests with real infrastructure catch edge cases that unit tests miss #testing-strategy
- [parameters] Consistent method signatures (workflow_id first) reduce cognitive overhead #api-design
- [maintenance] Fewer workflows and dependencies reduce long-term maintenance burden #operational-excellence
- [debugging] Plain Python exceptions are clearer than framework abstraction layers #developer-experience
- [resilience] Exponential backoff retry patterns handle flaky external API calls gracefully #error-handling
- [monitoring] Redis queue monitoring provides real-time operational visibility #observability
- [ci-cd] Bulk update commands enable automated tenant deployments in continuous delivery pipelines #automation
- [documentation] Comprehensive monitoring guides reduce operational learning curve #knowledge-management
- [error-logging] Context-aware log levels (INFO for expected errors, ERROR for unexpected) improve signal-to-noise ratio #logging-strategy
- [job-uniqueness] ARQ job uniqueness with predictable tenant-based IDs prevents duplicate operations and resource waste #deduplication
## Implementation Notes
### Configuration Integration
- **Redis Configuration**: Add Redis settings to existing `apps/cloud/src/basic_memory_cloud/config.py`
- **Local Development**: Leverage existing Redis setup from `docker-compose.yml`
- **Production**: Use Upstash Redis configuration for production environments
### Docker Entrypoint Strategy
Create `entrypoint.sh` script to toggle between API server and worker processes using single Docker image:
```bash
#!/bin/bash
# Entrypoint script for Basic Memory Cloud service
# Supports multiple process types: api, worker
set -e
case "$1" in
"api")
echo "Starting Basic Memory Cloud API server..."
exec uvicorn basic_memory_cloud.main:app \
--host 0.0.0.0 \
--port 8000 \
--log-level info
;;
"worker")
echo "Starting Basic Memory Cloud ARQ worker..."
# For ARQ worker implementation
exec python -m arq basic_memory_cloud.jobs.settings.WorkerSettings
;;
*)
echo "Usage: $0 {api|worker}"
echo " api - Start the FastAPI server"
echo " worker - Start the ARQ worker"
exit 1
;;
esac
```
### Fly.io Process Groups Configuration
Use separate machine groups for API and worker processes with independent scaling:
```toml
# fly.toml app configuration for basic-memory-cloud
app = 'basic-memory-cloud-dev-basic-machines'
primary_region = 'dfw'
org = 'basic-machines'
kill_signal = 'SIGINT'
kill_timeout = '5s'
[build]
# Process groups for API server and worker
[processes]
api = "api"
worker = "worker"
# Machine scaling configuration
[[machine]]
size = 'shared-cpu-1x'
processes = ['api']
min_machines_running = 1
auto_stop_machines = false
auto_start_machines = true
[[machine]]
size = 'shared-cpu-1x'
processes = ['worker']
min_machines_running = 1
auto_stop_machines = false
auto_start_machines = true
[env]
# Python configuration
PYTHONUNBUFFERED = '1'
PYTHONPATH = '/app'
# Logging configuration
LOG_LEVEL = 'DEBUG'
# Redis configuration for ARQ
REDIS_URL = 'redis://basic-memory-cloud-redis.upstash.io'
# Database configuration
DATABASE_HOST = 'basic-memory-cloud-db-dev-basic-machines.internal'
DATABASE_PORT = '5432'
DATABASE_NAME = 'basic_memory_cloud'
DATABASE_USER = 'postgres'
DATABASE_SSL = 'true'
# Worker configuration
ARQ_MAX_JOBS = '10'
ARQ_KEEP_RESULT = '3600'
# Fly.io configuration
FLY_ORG = 'basic-machines'
FLY_REGION = 'dfw'
# Internal service - no external HTTP exposure for worker
# API accessible via basic-memory-cloud-dev-basic-machines.flycast:8000
[[vm]]
size = 'shared-cpu-1x'
```
### Benefits of This Architecture
- **Single Docker Image**: Both API and worker use same container with different entrypoints
- **Independent Scaling**: Scale API and worker processes separately based on demand
- **Clean Separation**: Web traffic handling separate from background job processing
- **Existing Infrastructure**: Leverages current PostgreSQL + Redis setup without complexity
- **Hybrid State Management**: Redis for queue operations, PostgreSQL for persistent workflow tracking
## Relations
- implements [[SPEC-8 TigrisFS Integration]]
- follows [[SPEC-1 Specification-Driven Development Process]]
- supersedes previous multi-workflow architecture
@@ -31,8 +31,6 @@ HTTP requests to the API suffer from 350ms-2.6s latency overhead **before** any
This creates compounding effects with tenant auto-start delays and increases timeout risk in cloud deployments.
Github issue: https://github.com/basicmachines-co/basic-memory-cloud/issues/82
## What
This optimization affects the **core basic-memory repository** components:
@@ -170,76 +168,19 @@ Validation Checklist
- Documentation: Performance optimization documented in README
- Cloud Integration: basic-memory-cloud sees performance benefits
## Implementation Status ✅ COMPLETED
**Implementation Date**: 2025-09-26
**Branch**: `feature/spec-11-api-performance-optimization`
**Commit**: `771f60b`
### ✅ Phase 1: Database Connection Caching - IMPLEMENTED
**Files Modified:**
- `src/basic_memory/api/app.py` - Added database connection caching in app.state
- `src/basic_memory/deps.py` - Updated get_engine_factory() to use cached connections
- `src/basic_memory/config.py` - Added skip_initialization_sync configuration flag
**Implementation Details:**
1. **API Lifespan Caching**: Database engine and session_maker cached in app.state during startup
2. **Dependency Injection Optimization**: get_engine_factory() now returns cached connections instead of calling get_or_create_db()
3. **Project Reconciliation Removal**: Eliminated expensive reconcile_projects_with_config() from API startup
4. **CLI Fallback Preserved**: Non-API contexts continue to work with fallback database initialization
### ✅ Performance Validation - ACHIEVED
**Live Testing Results** (2025-09-26 14:03-14:09):
| Operation | Before | After | Improvement |
|-----------|--------|-------|-------------|
| `read_note` | 350ms-2.6s | **20ms** | **95-99% faster** |
| `edit_note` | 350ms-2.6s | **218ms** | **75-92% faster** |
| `search_notes` | 350ms-2.6s | **<500ms** | **Responsive** |
| `list_memory_projects` | N/A | **<100ms** | **Fast** |
**Key Achievements:**
-**95-99% improvement** in read operations (primary workflow)
-**75-92% improvement** in edit operations
-**Zero overhead** for project switching
-**Database connection overhead eliminated** (0ms vs 50-100ms)
-**Project reconciliation delays removed** from API requests
-**<500ms target achieved** for all operations except write (which includes file sync)
### ✅ Backwards Compatibility - MAINTAINED
- All existing functionality preserved
- CLI operations unaffected
- Fallback for non-API contexts maintained
- No breaking changes to existing APIs
- Optional configuration with safe defaults
### ✅ Testing Validation - PASSED
- Integration tests passing
- Type checking clear
- Linting checks passed
- Live testing with real MCP tools successful
- Multi-project workflows validated
- Rapid project switching validated
## Notes
Notes
Implementation Priority:
- Phase 1 COMPLETED: Database connection caching provides 95%+ performance gains
- Phase 2 NOT NEEDED: Project reconciliation removal achieved the goals
- Phase 3 INCLUDED: skip_initialization_sync flag added
- Phase 1 provides 80% of performance gains and should be implemented first
- Phase 2 provides remaining 20% and addresses edge cases
- Phase 3 is optional for maximum cloud optimization
Risk Mitigation:
- All changes backwards compatible implemented
- Gradual implementation successful (Phase 1 → validation)
- Easy rollback via configuration flags available
- All changes backwards compatible
- Gradual rollout possible (Phase 1 → 2 → 3)
- Easy rollback via configuration flags
Cloud Integration:
- This optimization directly addresses basic-memory-cloud issue #82
- Changes in core basic-memory will benefit all cloud tenants
- No changes needed in basic-memory-cloud itself
**Result**: SPEC-11 performance optimizations successfully implemented and validated. The 95-99% improvement in MCP tool response times exceeds the original 50-80% target, providing exceptional performance gains for cloud deployments and local usage.
- This optimization directly addresses basic-memory-cloud issue #82
- Changes in core basic-memory will benefit all cloud tenants
- No changes needed in basic-memory-cloud itself
@@ -0,0 +1,182 @@
# SPEC-12: OpenTelemetry Observability
## Why
We need comprehensive observability for basic-memory-cloud to:
- Track request flows across our multi-tenant architecture (MCP → Cloud → API services)
- Debug performance issues and errors in production
- Understand user behavior and system usage patterns
- Correlate issues to specific tenants for targeted debugging
- Monitor service health and latency across the distributed system
Currently, we only have basic logging without request correlation or distributed tracing capabilities.
## What
Implement OpenTelemetry instrumentation across all basic-memory-cloud services with:
### Core Requirements
1. **Distributed Tracing**: End-to-end request tracing from MCP gateway through to tenant API instances
2. **Tenant Correlation**: All traces tagged with tenant_id, user_id, and workos_user_id
3. **Service Identification**: Clear service naming and namespace separation
4. **Auto-instrumentation**: Automatic tracing for FastAPI, SQLAlchemy, HTTP clients
5. **Grafana Cloud Integration**: Direct OTLP export to Grafana Cloud Tempo
### Services to Instrument
- **MCP Gateway** (basic-memory-mcp): Entry point with JWT extraction
- **Cloud Service** (basic-memory-cloud): Provisioning and management operations
- **API Service** (basic-memory-api): Tenant-specific instances
- **Worker Processes** (ARQ workers): Background job processing
### Key Trace Attributes
- `tenant.id`: UUID from UserProfile.tenant_id
- `user.id`: WorkOS user identifier
- `user.email`: User email for debugging
- `service.name`: Specific service identifier
- `service.namespace`: Environment (development/production)
- `operation.type`: Business operation (provision/update/delete)
- `tenant.app_name`: Fly.io app name for tenant instances
## How
### Phase 1: Setup OpenTelemetry SDK
1. Add OpenTelemetry dependencies to each service's pyproject.toml:
```python
"opentelemetry-distro[otlp]>=1.29.0",
"opentelemetry-instrumentation-fastapi>=0.50b0",
"opentelemetry-instrumentation-httpx>=0.50b0",
"opentelemetry-instrumentation-sqlalchemy>=0.50b0",
"opentelemetry-instrumentation-logging>=0.50b0",
```
2. Create shared telemetry initialization module (`apps/shared/telemetry.py`)
3. Configure Grafana Cloud OTLP endpoint via environment variables:
```bash
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-us-east-2.grafana.net/otlp
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic[token]
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
```
### Phase 2: Instrument MCP Gateway
1. Extract tenant context from AuthKit JWT in middleware
2. Create root span with tenant attributes
3. Propagate trace context to downstream services via headers
### Phase 3: Instrument Cloud Service
1. Continue trace from MCP gateway
2. Add operation-specific attributes (provisioning events)
3. Instrument ARQ worker jobs for async operations
4. Track Fly.io API calls and latency
### Phase 4: Instrument API Service
1. Extract tenant context from JWT
2. Add machine-specific metadata (instance ID, region)
3. Instrument database operations with SQLAlchemy
4. Track MCP protocol operations
### Phase 5: Configure and Deploy
1. Add OTLP configuration to `.env.example` and `.env.example.secrets`
2. Set Fly.io secrets for production deployment
3. Update Dockerfiles to use `opentelemetry-instrument` wrapper
4. Deploy to development environment first for testing
## How to Evaluate
### Success Criteria
1. **End-to-end traces visible in Grafana Cloud** showing complete request flow
2. **Tenant filtering works** - Can filter traces by tenant_id to see all requests for a user
3. **Service maps accurate** - Grafana shows correct service dependencies
4. **Performance overhead < 5%** - Minimal latency impact from instrumentation
5. **Error correlation** - Can trace errors back to specific tenant and operation
### Testing Checklist
- [x] Single request creates connected trace across all services
- [x] Tenant attributes present on all spans
- [x] Background jobs (ARQ) appear in traces
- [x] Database queries show in trace timeline
- [x] HTTP calls to Fly.io API tracked
- [x] Traces exported successfully to Grafana Cloud
- [x] Can search traces by tenant_id in Grafana
- [x] Service dependency graph shows correct flow
### Monitoring Success
- All services reporting traces to Grafana Cloud
- No OTLP export errors in logs
- Trace sampling working correctly (if implemented)
- Resource usage acceptable (CPU/memory)
## Dependencies
- Grafana Cloud account with OTLP endpoint configured
- OpenTelemetry Python SDK v1.29.0+
- FastAPI instrumentation compatibility
- Network access from Fly.io to Grafana Cloud
## Implementation Assignment
**Recommended Agent**: python-developer
- Requires Python/FastAPI expertise
- Needs understanding of distributed systems
- Must implement middleware and context propagation
- Should understand OpenTelemetry SDK and instrumentation
## Follow-up Tasks
### Enhanced Log Correlation
While basic trace-to-log correlation works automatically via OpenTelemetry logging instrumentation, consider adding structured logging for improved log filtering:
1. **Structured Logging Context**: Add `logger.bind()` calls to inject tenant/user context directly into log records
2. **Custom Loguru Formatter**: Extract OpenTelemetry span attributes for better log readability
3. **Direct Log Filtering**: Enable searching logs directly by tenant_id, workflow_id without going through traces
This would complement the existing automatic trace correlation and provide better log search capabilities.
## Alternative Solution: Logfire
After implementing OpenTelemetry with Grafana Cloud, we discovered limitations in the observability experience:
- Traces work but lack useful context without correlated logs
- Setting up log correlation with Grafana is complex and requires additional infrastructure
- The developer experience for Python observability is suboptimal
### Logfire Evaluation
**Pydantic Logfire** offers a compelling alternative that addresses your specific requirements:
#### Core Requirements Match
- ✅ **User Activity Tracking**: Automatic request tracing with business context
- ✅ **Error Monitoring**: Built-in exception tracking with full context
- ✅ **Performance Metrics**: Automatic latency and performance monitoring
- ✅ **Request Tracing**: Native distributed tracing across services
- ✅ **Log Correlation**: Seamless trace-to-log correlation without setup
#### Key Advantages
1. **Python-First Design**: Built specifically for Python/FastAPI applications by the Pydantic team
2. **Simple Integration**: `pip install logfire` + `logfire.configure()` vs complex OTLP setup
3. **Automatic Correlation**: Logs automatically include trace context without manual configuration
4. **Real-time SQL Interface**: Query spans and logs using SQL with auto-completion
5. **Better Developer UX**: Purpose-built observability UI vs generic Grafana dashboards
6. **Loguru Integration**: `logger.configure(handlers=[logfire.loguru_handler()])` maintains existing logging
#### Pricing Assessment
- **Free Tier**: 10M spans/month (suitable for development and small production workloads)
- **Transparent Pricing**: $1 per million spans/metrics after free tier
- **No Hidden Costs**: No per-host fees, only usage-based metering
- **Production Ready**: Recently exited beta, enterprise features available
#### Migration Path
The existing OpenTelemetry instrumentation is compatible - Logfire uses OpenTelemetry under the hood, so the current spans and attributes would work unchanged.
### Recommendation
**Consider migrating to Logfire** for the following reasons:
1. It directly addresses the "next to useless" traces problem by providing integrated logs
2. Dramatically simpler setup and maintenance compared to Grafana Cloud + custom log correlation
3. Better ROI on observability investment with purpose-built Python tooling
4. Free tier sufficient for current development needs with clear scaling path
The current Grafana Cloud implementation provides a solid foundation and could remain as a backup/export target, while Logfire becomes the primary observability platform.
## Status
**Created**: 2024-01-28
**Status**: Completed (OpenTelemetry + Grafana Cloud)
**Next Phase**: Evaluate Logfire migration
**Priority**: High - Critical for production observability
@@ -340,398 +340,6 @@ This architecture makes the fix comprehensive and maintainable.
- Want to reduce database dependency
- Scale requires fewer database queries
## Post-Deployment Test Plan
This test plan should be executed after deploying the cloud service to verify subscription validation works end-to-end.
### Prerequisites
Before testing, ensure you have:
- [ ] Cloud service deployed with Phase 1 changes
- [ ] CLI installed with Phase 2 changes (`basic-memory` from local dev)
- [ ] Access to database to check/modify subscription status
- [ ] Two test user accounts:
- User A: No subscription (fresh WorkOS signup)
- User B: Active subscription (via Polar or manual DB insert)
### Test Execution
#### Test 1: User Without Subscription (Blocked Access) ❌
**Setup:**
1. Create fresh WorkOS account (User A) via AuthKit
2. Verify in database: No subscription record exists for User A's `workos_user_id`
**Test Steps:**
```bash
# Step 1: Attempt login
bm cloud login
```
**Expected Results:**
- ✅ OAuth flow completes successfully
- ✅ JWT token obtained and stored in `~/.basic-memory/auth/token`
- ❌ Login fails with "Subscription Required" error
- ✅ Error message displays:
- "✗ Subscription Required"
- "Active subscription required for CLI access"
- Subscribe URL: "https://basicmemory.com/subscribe"
- Instructions to run `bm cloud login` after subscribing
- ❌ Cloud mode NOT enabled (check with `bm cloud status`)
**Test Steps (continued):**
```bash
# Step 2: Attempt to access cloud features
bm cloud status
# Step 3: Try direct API call
curl -H "Authorization: Bearer <token>" https://<cloud-host>/proxy/health
```
**Expected Results:**
- ✅ `bm cloud status` shows "Mode: Local (disabled)"
- ✅ Direct API call returns 403 with subscription_required error
**Database Verification:**
```sql
-- Verify no subscription exists
SELECT * FROM subscriptions
WHERE workos_user_id = '<user-a-workos-id>';
-- Should return 0 rows
```
---
#### Test 2: User With Active Subscription (Full Access) ✅
**Setup:**
1. Use User B with active subscription
2. Verify in database: Subscription exists with `status = 'active'` and `current_period_end > NOW()`
**Database Verification:**
```sql
-- Verify active subscription exists
SELECT workos_user_id, status, current_period_end
FROM subscriptions
WHERE workos_user_id = '<user-b-workos-id>';
-- Should show: status='active', current_period_end in future
```
**Test Steps:**
```bash
# Step 1: Login
bm cloud login
# Step 2: Check cloud mode
bm cloud status
# Step 3: Setup bisync
bm cloud setup
# Step 4: Test MCP tools via proxy
curl -H "Authorization: Bearer <token>" \
https://<cloud-host>/proxy/<project-name>/health
# Step 5: List projects
bm project list
# Step 6: Create a test note
bm tool write-note \
--title "Test Note" \
--folder "test-project" \
--content "Testing subscription validation"
```
**Expected Results:**
- ✅ Login succeeds without errors
- ✅ Cloud mode enabled: "Mode: Cloud (enabled)"
- ✅ Cloud instance health check succeeds
- ✅ Bisync setup completes successfully
- ✅ Direct API calls succeed (200 OK)
- ✅ Projects list successfully
- ✅ Note creation succeeds
---
#### Test 3: Subscription Expiration (Access Revoked) 🔄
**Setup:**
1. Use User B (currently has active subscription and cloud mode enabled)
2. User should be able to access cloud features initially
**Test Steps:**
```bash
# Step 1: Verify current access works
bm cloud status
# Should show "Cloud (enabled)" and healthy instance
# Step 2: Expire subscription in database
# (See SQL below)
# Step 3: Attempt to access cloud features
bm cloud status
# Step 4: Try to login again
bm cloud logout
bm cloud login
```
**Database Operations:**
```sql
-- Expire the subscription
UPDATE subscriptions
SET status = 'cancelled',
current_period_end = NOW() - INTERVAL '1 day'
WHERE workos_user_id = '<user-b-workos-id>';
-- Verify expiration
SELECT workos_user_id, status, current_period_end
FROM subscriptions
WHERE workos_user_id = '<user-b-workos-id>';
-- Should show: status='cancelled', current_period_end in past
```
**Expected Results:**
- ❌ `bm cloud status` fails with 403 subscription_required error
- ❌ Re-login fails with "Subscription Required" error
- ✅ Error includes subscribe URL
---
#### Test 4: Subscription Renewal (Access Restored) ✅
**Setup:**
1. Continue from Test 3 (User B with expired subscription)
**Test Steps:**
```bash
# Step 1: Renew subscription in database
# (See SQL below)
# Step 2: Login again
bm cloud login
# Step 3: Verify access restored
bm cloud status
# Step 4: Test project access
bm project list
```
**Database Operations:**
```sql
-- Renew the subscription
UPDATE subscriptions
SET status = 'active',
current_period_end = NOW() + INTERVAL '30 days'
WHERE workos_user_id = '<user-b-workos-id>';
-- Verify renewal
SELECT workos_user_id, status, current_period_end
FROM subscriptions
WHERE workos_user_id = '<user-b-workos-id>';
-- Should show: status='active', current_period_end 30 days in future
```
**Expected Results:**
- ✅ Login succeeds
- ✅ Cloud mode enabled
- ✅ Cloud status shows healthy
- ✅ Projects list successfully
- ✅ **Access immediately restored** (no delay)
---
#### Test 5: Endpoint Coverage (All Protected Endpoints) 🔐
**Setup:**
1. Use User A (no subscription) to test blocked access
2. Use User B (active subscription) to test allowed access
**Test Matrix:**
| Endpoint | Method | User A (No Sub) | User B (Active Sub) |
|----------|--------|----------------|---------------------|
| `/proxy/health` | GET | 403 ❌ | 200 ✅ |
| `/proxy/<project>/health` | GET | 403 ❌ | 200 ✅ |
| `/proxy/<project>/search` | POST | 403 ❌ | 200 ✅ |
| `/tenant/mount/info` | GET | 403 ❌ | 200 ✅ |
| `/tenant/mount/credentials` | POST | 403 ❌ | 200 ✅ |
**Test Commands:**
```bash
# Get tokens for both users
TOKEN_A="<user-a-token>"
TOKEN_B="<user-b-token>"
# Test /proxy/health
curl -H "Authorization: Bearer $TOKEN_A" \
https://<cloud-host>/proxy/health
# Expected: 403 with subscription_required
curl -H "Authorization: Bearer $TOKEN_B" \
https://<cloud-host>/proxy/health
# Expected: 200 OK
# Test /tenant/mount/info
curl -H "Authorization: Bearer $TOKEN_A" \
https://<cloud-host>/tenant/mount/info
# Expected: 403 with subscription_required
curl -H "Authorization: Bearer $TOKEN_B" \
https://<cloud-host>/tenant/mount/info
# Expected: 200 OK with mount info
# Test /proxy/<project>/health
curl -H "Authorization: Bearer $TOKEN_B" \
https://<cloud-host>/proxy/<project-name>/health
# Expected: 200 OK
```
---
#### Test 6: Error Response Format Validation 📋
**Test Steps:**
```bash
# Get 403 response for user without subscription
curl -i -H "Authorization: Bearer $TOKEN_A" \
https://<cloud-host>/proxy/health
```
**Expected Response Format:**
```http
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "subscription_required",
"message": "Active subscription required for CLI access",
"subscribe_url": "https://basicmemory.com/subscribe"
}
```
**Validation Checklist:**
- ✅ Status code is exactly 403
- ✅ Response is valid JSON
- ✅ `error` field equals "subscription_required"
- ✅ `message` field is present and informative
- ✅ `subscribe_url` field is present and valid URL
---
#### Test 7: Admin Access Bypass 👑
**Purpose:** Verify admin users can still access admin endpoints without subscription
**Setup:**
1. Use admin user account (member of admin organization in WorkOS)
**Test Steps:**
```bash
# Login as admin
python -m basic_memory_cloud.cli.tenant_cli login
# List tenants (admin-only endpoint)
python -m basic_memory_cloud.cli.tenant_cli list-tenants
# Create tenant (admin-only endpoint)
python -m basic_memory_cloud.cli.tenant_cli create-tenant \
--workos-user-id <test-user-id>
```
**Expected Results:**
- ✅ Admin login succeeds
- ✅ Admin can access `/tenants/*` endpoints
- ✅ Admin operations work regardless of subscription status
- ✅ Admin endpoints use `AdminUserHybridDep` (not affected by subscription check)
---
### Test Results Template
Copy this template to track your test execution:
```markdown
## SPEC-13 Test Execution - [Date]
### Environment
- Cloud Service: [URL]
- Cloud Service Version: [commit/tag]
- CLI Version: [commit/tag]
- Database: [production/staging]
### Test Results
#### Test 1: User Without Subscription ❌
- [ ] OAuth flow succeeds
- [ ] Subscription error displayed
- [ ] Subscribe URL shown
- [ ] Cloud mode NOT enabled
- [ ] Direct API call blocked
**Issues:** [None / List issues]
#### Test 2: User With Active Subscription ✅
- [ ] Login succeeds
- [ ] Cloud mode enabled
- [ ] Health check passes
- [ ] Bisync setup works
- [ ] MCP tools work
- [ ] Projects accessible
**Issues:** [None / List issues]
#### Test 3: Subscription Expiration 🔄
- [ ] Active user can access initially
- [ ] After expiration, access blocked
- [ ] Error message clear
- [ ] Cloud status fails appropriately
**Issues:** [None / List issues]
#### Test 4: Subscription Renewal ✅
- [ ] Renewed subscription in DB
- [ ] Login succeeds immediately
- [ ] Access fully restored
- [ ] No caching delays
**Issues:** [None / List issues]
#### Test 5: Endpoint Coverage 🔐
- [ ] All proxy endpoints protected
- [ ] All mount endpoints protected
- [ ] Subscription check consistent
- [ ] Error responses correct
**Issues:** [None / List issues]
#### Test 6: Error Response Format 📋
- [ ] 403 status code
- [ ] Valid JSON response
- [ ] All required fields present
- [ ] Subscribe URL valid
**Issues:** [None / List issues]
#### Test 7: Admin Access Bypass 👑
- [ ] Admin login works
- [ ] Admin endpoints accessible
- [ ] No subscription requirement
**Issues:** [None / List issues]
### Overall Result
- [ ] All tests passed
- [ ] Ready for production
**Summary:** [Brief summary of test execution]
**Sign-off:** [Your name/date]
```
---
## How to Evaluate
### Success Criteria
@@ -1065,35 +673,35 @@ The extra HTTP hop is minimal (< 10ms) and worth it for architectural benefits.
- [ ] Call `/tenant/mount/info` with valid JWT and active subscription → expect 200
- [ ] Verify error response structure matches spec
### Phase 2: CLI (basic-memory)
### Phase 2: CLI (basic-memory)
#### Task 2.1: Review and understand CLI authentication flow
#### Task 2.1: Review and understand CLI authentication flow
**Files**: `src/basic_memory/cli/commands/cloud/`
- [x] Read `core_commands.py` to understand current login flow
- [x] Read `api_client.py` to understand current error handling
- [x] Identify where 403 errors should be caught
- [x] Identify what error messages should be displayed
- [x] Document current behavior in spec if needed
- [ ] Read `core_commands.py` to understand current login flow
- [ ] Read `api_client.py` to understand current error handling
- [ ] Identify where 403 errors should be caught
- [ ] Identify what error messages should be displayed
- [ ] Document current behavior in spec if needed
#### Task 2.2: Update API client error handling
#### Task 2.2: Update API client error handling
**File**: `src/basic_memory/cli/commands/cloud/api_client.py`
- [x] Add custom exception class `SubscriptionRequiredError` (or similar)
- [x] Update HTTP error handling to parse 403 responses
- [x] Extract `error`, `message`, and `subscribe_url` from error detail
- [x] Raise specific exception for subscription_required errors
- [x] Run `just typecheck` in basic-memory repo to verify types
- [ ] Add custom exception class `SubscriptionRequiredError` (or similar)
- [ ] Update HTTP error handling to parse 403 responses
- [ ] Extract `error`, `message`, and `subscribe_url` from error detail
- [ ] Raise specific exception for subscription_required errors
- [ ] Run `just typecheck` in basic-memory repo to verify types
#### Task 2.3: Update CLI login command error handling
#### Task 2.3: Update CLI login command error handling
**File**: `src/basic_memory/cli/commands/cloud/core_commands.py`
- [x] Import the subscription error exception
- [x] Wrap login flow with try/except for subscription errors
- [x] Display user-friendly error message with rich console
- [x] Show subscribe URL prominently
- [x] Provide actionable next steps
- [x] Run `just typecheck` to verify types
- [ ] Import the subscription error exception
- [ ] Wrap login flow with try/except for subscription errors
- [ ] Display user-friendly error message with rich console
- [ ] Show subscribe URL prominently
- [ ] Provide actionable next steps
- [ ] Run `just typecheck` to verify types
**Expected error handling**:
```python
@@ -1111,33 +719,30 @@ except SubscriptionRequiredError as e:
raise typer.Exit(1)
```
#### Task 2.4: Update CLI tests
**File**: `tests/cli/test_cloud_authentication.py` (created)
#### Task 2.4: Update CLI tests
**File**: `tests/cli/test_cloud_commands.py`
- [x] Add test: `test_login_without_subscription_shows_error()`
- [ ] Add test: `test_login_without_subscription_shows_error()`
- Mock 403 subscription_required response
- Call login command
- Assert error message displayed
- Assert subscribe URL shown
- [x] Add test: `test_login_with_subscription_succeeds()`
- [ ] Add test: `test_login_with_subscription_succeeds()`
- Mock successful authentication + subscription check
- Call login command
- Assert success message
- [x] Add test: `test_parse_subscription_required_error()` (API client error parsing)
- [x] Add test: `test_parse_generic_403_error()` (generic 403 handling)
- [x] Add test: `test_login_authentication_failure()` (auth failure handling)
- [x] Run `uv run pytest` to verify tests pass (5/5 passed)
- [ ] Run `just test` to verify tests pass
#### Task 2.5: Update CLI documentation
**File**: `docs/cloud-cli.md`
#### Task 2.5: Update CLI documentation
**File**: `docs/cloud-cli.md` (in basic-memory-docs repo)
- [x] Add "Prerequisites" section if not present
- [x] Document subscription requirement
- [x] Add "Troubleshooting" section
- [x] Document "Subscription Required" error
- [x] Provide subscribe URL
- [x] Add FAQ entry about subscription errors
- [x] Build docs locally to verify formatting
- [ ] Add "Prerequisites" section if not present
- [ ] Document subscription requirement
- [ ] Add "Troubleshooting" section
- [ ] Document "Subscription Required" error
- [ ] Provide subscribe URL
- [ ] Add FAQ entry about subscription errors
- [ ] Build docs locally to verify formatting
### Phase 3: End-to-End Testing
@@ -1227,12 +832,12 @@ Use this high-level checklist to track overall progress:
- [ ] Add integration tests for dependency
- [ ] Deploy and verify cloud service
### Phase 2: CLI Updates
- [x] Review CLI authentication flow
- [x] Update API client error handling
- [x] Update CLI login command error handling
- [x] Add CLI tests
- [x] Update CLI documentation
### Phase 2: CLI Updates 🔄
- [ ] Review CLI authentication flow
- [ ] Update API client error handling
- [ ] Update CLI login command error handling
- [ ] Add CLI tests
- [ ] Update CLI documentation
### Phase 3: End-to-End Testing 🧪
- [ ] Create test user accounts
@@ -1310,118 +915,3 @@ Use this high-level checklist to track overall progress:
- This spec prioritizes security over convenience - better to block unauthorized access than risk revenue loss
- Clear error messages are critical - users should understand why they're blocked and how to resolve it
- Consider adding telemetry to track subscription_required errors for monitoring signup conversion
## Implementation Log
### Phase 2 Completion - 2025-10-03
Phase 2 (CLI Updates) completed successfully with the following implementation:
**Files Modified:**
- `src/basic_memory/cli/commands/cloud/api_client.py` - Added `SubscriptionRequiredError` exception and enhanced error handling
- `src/basic_memory/cli/commands/cloud/core_commands.py` - Updated login command to verify subscription access
- `docs/cloud-cli.md` - Added Prerequisites and Subscription Issues sections
**Files Created:**
- `tests/cli/test_cloud_authentication.py` - Comprehensive test coverage (6 tests, all passing)
**Key Implementation Details:**
- `SubscriptionRequiredError` exception with `subscribe_url` field for user guidance
- Enhanced `CloudAPIError` to include `status_code` and `detail` fields
- Login flow now calls `/proxy/health` to verify subscription before enabling cloud mode
- User-friendly error messages with direct subscribe link
- 100% test coverage of new error handling paths
**Test Results:**
- All 6 tests passing
- Type checking: 0 errors, 0 warnings
- Linting: All checks passed
**Next Steps:**
- Phase 3: End-to-End Testing (manual testing with real users, subscription state transitions)
- Phase 1: Complete remaining cloud service tests (unit tests, integration tests, deployment verification)
---
### End-to-End Test Execution - 2025-10-03
**Environment:**
- Cloud Service: https://cloud.basicmemory.com
- Cloud Service Version: Phase 1 deployed (with subscription validation)
- CLI Version: Phase 2 implementation (local dev build)
- Database: Production
**Test Results:**
#### Test 1: User Without Subscription ✅ PASSED
- [x] OAuth flow succeeds
- [x] Subscription error displayed
- [x] Subscribe URL shown
- [x] Cloud mode NOT enabled
- [x] Clean error output (no traceback)
**Output:**
```
✅ Successfully authenticated with WorkOS!
Verifying subscription access...
✗ Subscription Required
Active subscription required
Subscribe at: https://basicmemory.com/subscribe
Once you have an active subscription, run bm cloud login again.
```
**Issues:** None
---
#### Test 2: User With Active Subscription ✅ PASSED
- [x] Login succeeds
- [x] Cloud mode enabled
- [x] Clean success message
- [x] Ready for cloud operations
**Output:**
```
✅ Successfully authenticated with WorkOS!
Verifying subscription access...
✓ Cloud mode enabled
All CLI commands now work against https://cloud.basicmemory.com
```
**Issues:** None
---
**Additional Implementation Notes:**
**API Response Format Compatibility:**
- Cloud service returns errors in FastAPI HTTPException format (nested under `"detail"` key)
- CLI correctly handles both nested and flat response formats
- Error parsing logic:
```python
detail_obj = error_detail.get("detail", error_detail)
if isinstance(detail_obj, dict) and detail_obj.get("error") == "subscription_required":
# Handle subscription error
```
**Updated Test Coverage:**
- Added `test_parse_subscription_required_error_flat_format()` for backward compatibility
- Total: 6 tests, all passing
- Files updated:
- `src/basic_memory/cli/commands/cloud/api_client.py` - Support both response formats
- `tests/cli/test_cloud_authentication.py` - Added flat format test
**Overall Result:**
- [x] Core authentication flows validated
- [x] Error handling working as designed
- [x] User experience is clean and helpful
- [x] Ready for production use
**Summary:**
SPEC-13 Phase 2 successfully validated in production environment. Both unauthorized and authorized user flows work correctly. The subscription validation is functioning end-to-end with clear, user-friendly error messages and seamless success path. No issues discovered during testing.
**Sign-off:** Phase 2 Complete - 2025-10-03
@@ -0,0 +1,210 @@
---
title: 'SPEC-14: Cloud Git Versioning & GitHub Backup'
type: spec
permalink: specs/spec-14-cloud-git-versioning
tags:
- git
- github
- backup
- versioning
- cloud
related:
- specs/spec-9-multi-project-bisync
- specs/spec-9-follow-ups-conflict-sync-and-observability
status: deferred
---
# SPEC-14: Cloud Git Versioning & GitHub Backup
**Status: DEFERRED** - Postponed until multi-user/teams feature development. Using S3 versioning (SPEC-9.1) for v1 instead.
## Why Deferred
**Original goals can be met with simpler solutions:**
- Version history → **S3 bucket versioning** (automatic, zero config)
- Offsite backup → **Tigris global replication** (built-in)
- Restore capability → **S3 version restore** (`bm cloud restore --version-id`)
- Collaboration → **Deferred to teams/multi-user feature** (not v1 requirement)
**Complexity vs value trade-off:**
- Git integration adds: committer service, puller service, webhooks, LFS, merge conflicts
- Risk: Loop detection between Git ↔ rclone bisync ↔ local edits
- S3 versioning gives 80% of value with 5% of complexity
**When to revisit:**
- Teams/multi-user features (PR-based collaboration workflow)
- User requests for commit messages and branch-based workflows
- Need for fine-grained audit trail beyond S3 object metadata
---
## Original Specification (for reference)
## Why
Early access users want **transparent version history**, easy **offsite backup**, and a familiar **restore/branching** workflow. Git/GitHub integration would provide:
- Auditable history of every change (who/when/why)
- Branches/PRs for review and collaboration
- Offsite private backup under the user's control
- Escape hatch: users can always `git clone` their knowledge base
**Note:** These goals are now addressed via S3 versioning (SPEC-9.1) for single-user use case.
## Goals
- **Transparent**: Users keep using Basic Memory; Git runs behind the scenes.
- **Private**: Push to a **private GitHub repo** that the user owns (or tenant org).
- **Reliable**: No data loss, deterministic mapping of filesystem ↔ Git.
- **Composable**: Plays nicely with SPEC9 bisync and upcoming conflict features (SPEC9 FollowUps).
**NonGoals (for v1):**
- Finegrained perfile encryption in Git history (can be layered later).
- Large media optimization beyond Git LFS defaults.
## User Stories
1. *As a user*, I connect my GitHub and choose a private backup repo.
2. *As a user*, every change I make in cloud (or via bisync) is **committed** and **pushed** automatically.
3. *As a user*, I can **restore** a file/folder/project to a prior version.
4. *As a power user*, I can **git pull/push** directly to collaborate outside the app.
5. *As an admin*, I can enforce repo ownership (tenant org) and leastprivilege scopes.
## Scope
- **In scope:** Full repo backup of `/app/data/` (all projects) with optional selective subpaths.
- **Out of scope (v1):** Partial shallow mirrors; encrypted Git; crossprovider SCM (GitLab/Bitbucket).
## Architecture
### Topology
- **Authoritative working tree**: `/app/data/` (bucket mount) remains the source of truth (SPEC9).
- **Bare repo** lives alongside: `/app/git/${tenant}/knowledge.git` (serverside).
- **Mirror remote**: `github.com/<owner>/<repo>.git` (private).
```mermaid
flowchart LR
A[/Users & Agents/] -->|writes/edits| B[/app/data/]
B -->|file events| C[Committer Service]
C -->|git commit| D[(Bare Repo)]
D -->|push| E[(GitHub Private Repo)]
E -->|webhook (push)| F[Puller Service]
F -->|git pull/merge| D
D -->|checkout/merge| B
```
### Services
- **Committer Service** (daemon):
- Watches `/app/data/` for changes (inotify/poll)
- Batches changes (debounce e.g. 25s)
- Writes `.bmmeta` (if present) into commit message trailer (see FollowUps)
- `git add -A && git commit -m "chore(sync): <summary>
BM-Meta: <json>"`
- Periodic `git push` to GitHub mirror (configurable interval)
- **Puller Service** (webhook target):
- Receives GitHub webhook (push) → `git fetch`
- **Fastforward** merges to `main` only; reject nonFF unless policy allows
- Applies changes back to `/app/data/` via clean checkout
- Emits sync events for Basic Memory indexers
### Auth & Security
- **GitHub App** (recommended): minimal scopes: `contents:read/write`, `metadata:read`, webhook.
- Tenantscoped installation; repo created in user account or tenant org.
- Tokens stored in KMS/secret manager; rotated automatically.
- Optional policy: allow only **FF merges** on `main`; nonFF requires PR.
### Repo Layout
- **Monorepo** (default): one repo per tenant mirrors `/app/data/` with subfolders per project.
- Optional multirepo mode (later): one repo per project.
### File Handling
- Honor `.gitignore` generated from `.bmignore.rclone` + BM defaults (cache, temp, state).
- **Git LFS** for large binaries (images, media) — auto track by extension/size threshold.
- Normalize newline + Unicode (aligns with FollowUps).
### Conflict Model
- **Primary concurrency**: SPEC9 FollowUps (`.bmmeta`, conflict copies) stays the first line of defense.
- **Git merges** are a **secondary** mechanism:
- Server only automerges **text** conflicts when trivial (FF or clean 3way).
- Otherwise, create `name (conflict from <branch>, <ts>).md` and surface via events.
### Data Flow vs Bisync
- Bisync (rclone) continues between local sync dir ↔ bucket.
- Git sits **cloudside** between bucket and GitHub.
- On **pull** from GitHub → files written to `/app/data/` → picked up by indexers & eventually by bisync back to users.
## CLI & UX
New commands (cloud mode):
- `bm cloud git connect` — Launch GitHub App installation; create private repo; store installation id.
- `bm cloud git status` — Show connected repo, last push time, last webhook delivery, pending commits.
- `bm cloud git push` — Manual push (rarely needed).
- `bm cloud git pull` — Manual pull/FF (admin only by default).
- `bm cloud snapshot -m "message"` — Create a tagged pointintime snapshot (git tag).
- `bm restore <path> --to <commit|tag>` — Restore file/folder/project to prior version.
Settings:
- `bm config set git.autoPushInterval=5s`
- `bm config set git.lfs.sizeThreshold=10MB`
- `bm config set git.allowNonFF=false`
## Migration & Backfill
- On connect, if repo empty: initial commit of entire `/app/data/`.
- If repo has content: require **onetime import** path (clone to staging, reconcile, choose direction).
## Edge Cases
- Massive deletes: gated by SPEC9 `max_delete` **and** Git prepush hook checks.
- Case changes and rename detection: rely on git rename heuristics + FollowUps move hints.
- Secrets: default ignore common secret patterns; allow custom deny list.
## Telemetry & Observability
- Emit `git_commit`, `git_push`, `git_pull`, `git_conflict` events with correlation IDs.
- `bm sync --report` extended with Git stats (commit count, delta bytes, push latency).
## Phased Plan
### Phase 0 — Prototype (1 sprint)
- Server: bare repo init + simple committer (batch every 10s) + manual GitHub token.
- CLI: `bm cloud git connect --token <PAT>` (devonly)
- Success: edits in `/app/data/` appear in GitHub within 30s.
### Phase 1 — GitHub App & Webhooks (12 sprints)
- Switch to GitHub App installs; create private repo; store installation id.
- Committer hardened (debounce 25s, backoff, retries).
- Puller service with webhook → FF merge → checkout to `/app/data/`.
- LFS autotrack + `.gitignore` generation.
- CLI surfaces status + logs.
### Phase 2 — Restore & Snapshots (1 sprint)
- `bm restore` for file/folder/project with dryrun.
- `bm cloud snapshot` tags + list/inspect.
- Policy: PRonly nonFF, admin override.
### Phase 3 — Selective & MultiRepo (nicetohave)
- Include/exclude projects; optional perproject repos.
- Advanced policies (branch protections, required reviews).
## Acceptance Criteria
- Changes to `/app/data/` are committed and pushed automatically within configurable interval (default ≤5s).
- GitHub webhook pull results in updated files in `/app/data/` (FFonly by default).
- LFS configured and functioning; large files don't bloat history.
- `bm cloud git status` shows connected repo and last push/pull times.
- `bm restore` restores a file/folder to a prior commit with a clear audit trail.
- Endtoend works alongside SPEC9 bisync without loops or data loss.
## Risks & Mitigations
- **Loop risk (Git ↔ Bisync)**: Writes to `/app/data/` → bisync → local → user edits → back again. *Mitigation*: Debounce, commit squashing, idempotent `.bmmeta` versioning, and watch exclusion windows during pull.
- **Repo bloat**: Lots of binary churn. *Mitigation*: default LFS, size threshold, optional mediaonly repo later.
- **Security**: Token leakage. *Mitigation*: GitHub App with shortlived tokens, KMS storage, scoped permissions.
- **Merge complexity**: Nontrivial conflicts. *Mitigation*: prefer FF; otherwise conflict copies + events; require PR for nonFF.
## Open Questions
- Do we default to **monorepo** per tenant, or offer projectperrepo at connect time?
- Should `restore` write to a branch and open a PR, or directly modify `main`?
- How do we expose Git history in UI (timeline view) without users dropping to CLI?
## Appendix: Sample Config
```json
{
"git": {
"enabled": true,
"repo": "https://github.com/<owner>/<repo>.git",
"autoPushInterval": "5s",
"allowNonFF": false,
"lfs": { "sizeThreshold": 10485760 }
}
}
```
@@ -41,16 +41,16 @@ Store Basic Memory configuration in the Tigris bucket and rebuild the database i
**Architecture:**
```bash
# Tigris Bucket (persistent, mounted at /mnt/tigris)
/mnt/tigris/
# Tigris Bucket (persistent, mounted at /app/data)
/app/data/
├── .basic-memory/
│ └── config.json # ← Project configuration (persistent, accessed via BASIC_MEMORY_CONFIG_DIR)
└── projects/ # ← Markdown files (persistent)
└── basic-memory/ # ← Markdown files (persistent, BASIC_MEMORY_HOME)
├── project1/
└── project2/
# Fly Machine (ephemeral)
~/.basic-memory/
/app/.basic-memory/
└── memory.db # ← Rebuilt on startup (fast local disk)
```
@@ -107,8 +107,8 @@ async def startup_sync():
```bash
# Machine environment variables
BASIC_MEMORY_CONFIG_DIR=/mnt/tigris/.basic-memory # Config read/written directly to Tigris
# memory.db stays in default location: ~/.basic-memory/memory.db (local ephemeral disk)
BASIC_MEMORY_CONFIG_DIR=/app/data/.basic-memory # Config read/written directly to Tigris
# memory.db stays in default location: /app/.basic-memory/memory.db (local ephemeral disk)
```
## Implementation Task List
@@ -118,20 +118,26 @@ BASIC_MEMORY_CONFIG_DIR=/mnt/tigris/.basic-memory # Config read/written directl
- [x] Test config loading from custom directory
- [x] Update tests to verify custom config dir works
### Phase 2: Tigris Bucket Structure
- [ ] Ensure `.basic-memory/` directory exists in Tigris bucket on tenant creation
- [ ] Initialize `config.json` in Tigris on first tenant deployment
- [ ] Verify TigrisFS handles hidden directories correctly
### Phase 2: Tigris Bucket Structure
- [x] Ensure `.basic-memory/` directory exists in Tigris bucket on tenant creation
- ✅ ConfigManager auto-creates on first run, no explicit provisioning needed
- [x] Initialize `config.json` in Tigris on first tenant deployment
- ✅ ConfigManager creates config.json automatically in BASIC_MEMORY_CONFIG_DIR
- [x] Verify TigrisFS handles hidden directories correctly
- ✅ TigrisFS supports hidden directories (verified in SPEC-8)
### Phase 3: Deployment Integration
- [ ] Set `BASIC_MEMORY_CONFIG_DIR` environment variable in machine deployment
- [ ] Ensure database rebuild runs on machine startup via initialization sync
- [ ] Handle first-time tenant setup (no config exists yet)
### Phase 3: Deployment Integration
- [x] Set `BASIC_MEMORY_CONFIG_DIR` environment variable in machine deployment
- ✅ Added to BasicMemoryMachineConfigBuilder in fly_schemas.py
- [x] Ensure database rebuild runs on machine startup via initialization sync
- ✅ sync_worker.py runs initialize_file_sync every 30s (already implemented)
- [x] Handle first-time tenant setup (no config exists yet)
- ✅ ConfigManager creates config.json on first initialization
- [ ] Test deployment workflow with config persistence
### Phase 4: Testing
- [x] Unit tests for config directory override
- [ ] Integration test: deploy → write config → redeploy → verify config persists
- [-] Integration test: deploy → write config → redeploy → verify config persists
- [ ] Integration test: deploy → add project → redeploy → verify project in config
- [ ] Performance test: measure db rebuild time on startup
@@ -175,7 +181,7 @@ BASIC_MEMORY_CONFIG_DIR=/mnt/tigris/.basic-memory # Config read/written directl
basic-memory project add "test-project" ~/test
# Verify config has project
cat /mnt/tigris/.basic-memory/config.json
cat /app/data/.basic-memory/config.json
# Redeploy machine
fly deploy --app basic-memory-{tenant_id}
@@ -261,4 +267,7 @@ BASIC_MEMORY_CONFIG_DIR=/mnt/tigris/.basic-memory # Config read/written directl
- 2025-10-08: Pivoted from Turso to Tigris-based config persistence
- 2025-10-08: Phase 1 complete - BASIC_MEMORY_CONFIG_DIR support added (PR #343)
- Next: Implement Phases 2-3 in basic-memory-cloud repository
- 2025-10-08: Phases 2-3 complete - Added BASIC_MEMORY_CONFIG_DIR to machine config
- Config now persists to /app/data/.basic-memory/config.json in Tigris bucket
- Database rebuild already working via sync_worker.py
- Ready for deployment testing (Phase 4)
+163 -82
View File
@@ -8,9 +8,60 @@ tags:
- cloud
- performance
- deployment
status: draft
status: in-progress
---
## Status Update
**Phase 0 (Basic Memory Refactor): ✅ COMPLETE**
- basic-memory PR #344: async_client context manager pattern implemented
- All 17 MCP tools updated to use `async with get_client() as client:`
- CLI commands updated to use context manager
- Removed `inject_auth_header()` and `headers.py` (~100 lines deleted)
- Factory pattern enables clean dependency injection
- Tests passing, typecheck clean
**Phase 0 Integration: ✅ COMPLETE**
- basic-memory-cloud updated to use async-client-context-manager branch
- Implemented `tenant_direct_client_factory()` with proper context manager pattern
- Removed module-level client override hacks
- Removed unnecessary `/proxy` prefix stripping (tools pass relative URLs)
- Typecheck and lint passing with proper noqa hints
- MCP tools confirmed working via inspector (local testing)
**Phase 1 (Code Consolidation): ✅ COMPLETE**
- MCP server mounted on Cloud FastAPI app at /mcp endpoint
- AuthKitProvider configured with WorkOS settings
- Combined lifespans (Cloud + MCP) working correctly
- JWT context middleware integrated
- All routes and MCP tools functional
**Phase 2 (Direct Tenant Transport): ✅ COMPLETE**
- TenantDirectTransport implemented with custom httpx transport
- Per-request JWT extraction via FastMCP DI
- Tenant lookup and signed header generation working
- Direct routing to tenant APIs (eliminating HTTP hop)
- Transport tests passing (11/11)
**Phase 3 (Testing & Validation): ✅ COMPLETE**
- Typecheck and lint passing across all services
- MCP OAuth authentication working in preview environment
- Tenant isolation via signed headers verified
- Fixed BM_TENANT_HEADER_SECRET mismatch between environments
- MCP tools successfully calling tenant APIs in preview
**Phase 4 (Deployment Configuration): ✅ COMPLETE**
- Updated apps/cloud/fly.template.toml with MCP environment variables
- Added HTTP/2 backend support for better MCP performance
- Added OAuth protected resource health check
- Removed MCP from preview deployment workflow
- Successfully deployed to preview environment (PR #113)
- All services operational at pr-113-basic-memory-cloud.fly.dev
**Next Steps:**
- Phase 5: Cleanup (remove apps/mcp directory)
- Phase 6: Production rollout and performance measurement
# SPEC-16: MCP Cloud Service Consolidation
## Why
@@ -100,7 +151,9 @@ app.include_router(provisioning_router)
### 2. Direct Tenant Transport (No HTTP Hop)
Instead of calling `/proxy`, MCP tools call tenant APIs directly via custom httpx transport:
Instead of calling `/proxy`, MCP tools call tenant APIs directly via custom httpx transport.
**Important:** No URL prefix stripping needed. The transport receives relative URLs like `/main/resource/notes/my-note` which are correctly routed to tenant APIs. The `/proxy` prefix only exists for web UI requests to the proxy router, not for MCP tools using the custom transport.
```python
# apps/cloud/src/basic_memory_cloud/transports/tenant_direct.py
@@ -139,28 +192,45 @@ class TenantDirectTransport(AsyncBaseTransport):
return response
```
Then override basic-memory's client before mounting MCP:
Then configure basic-memory's client factory before mounting MCP:
```python
# apps/cloud/src/basic_memory_cloud/main.py
from contextlib import asynccontextmanager
from basic_memory.mcp import async_client
from basic_memory_cloud.transports.tenant_direct import TenantDirectTransport
# Override basic-memory's HTTP client with direct transport
async_client.client = httpx.AsyncClient(
transport=TenantDirectTransport(),
base_url="http://direct"
)
# Configure factory for basic-memory's async_client
@asynccontextmanager
async def tenant_direct_client_factory():
"""Factory for creating clients with tenant direct transport."""
client = httpx.AsyncClient(
transport=TenantDirectTransport(),
base_url="http://direct",
)
try:
yield client
finally:
await client.aclose()
# Now mount MCP - tools will use direct transport
# Set factory BEFORE importing MCP tools
async_client.set_client_factory(tenant_direct_client_factory)
# NOW import - tools will use our factory
import basic_memory.mcp.tools
import basic_memory.mcp.prompts
from basic_memory.mcp.server import mcp
# Mount MCP - tools use direct transport via factory
app.mount("/mcp", mcp_app)
```
**Key benefits:**
- No changes to basic-memory code
- Clean dependency injection via factory pattern
- Per-request tenant resolution via FastMCP DI
- Eliminates HTTP hop entirely (~50 lines of code)
- Proper resource cleanup (client.aclose() guaranteed)
- Eliminates HTTP hop entirely
- /proxy endpoint remains for web UI
### 3. Keep /proxy Endpoint for Web UI
@@ -478,14 +548,15 @@ Remove manual auth header passing, use context manager:
- [x] Update any lingering references/docs (added deprecation notice to v15-docs/cloud-mode-usage.md)
#### 0.6 Testing
- [x] ~~Update test fixtures to use factory pattern~~ (Not needed - tests work fine as-is)
- [-] Update test fixtures to use factory pattern
- [x] Run full test suite in basic-memory
- [x] Verify cloud_mode_enabled works with CLIAuth injection (tested in preview env)
- [x] Verify cloud_mode_enabled works with CLIAuth injection
- [x] Run typecheck and linting
#### 0.7 Cloud Integration Prep
- [x] Update basic-memory-cloud pyproject.toml to use branch
- [x] Document factory usage pattern for cloud app
- [x] Implement factory pattern in cloud app main.py
- [x] Remove `/proxy` prefix stripping logic (not needed - tools pass relative URLs)
#### 0.8 Phase 0 Validation
@@ -496,18 +567,17 @@ Remove manual auth header passing, use context manager:
- [x] Linting passes (ruff)
- [x] Manual test: local mode works (ASGI transport)
- [x] Manual test: cloud login → cloud mode works (HTTP transport with auth)
- [x] No import of `inject_auth_header` anywhere
- [x] `headers.py` file deleted
- [x] `api_url` config removed
- [x] no use of `async_client.client` ✅
- [x] Tool functions properly scoped (client inside async with) - 15 tools ✅
- [x] CLI commands properly scoped (client inside async with) - 10 commands ✅
- [x] Prompts/resources properly scoped - 3 files ✅
- [x] No import of `inject_auth_header` anywhere
- [x] `headers.py` file deleted
- [x] `api_url` config removed
- [x] Tool functions properly scoped (client inside async with)
- [ ] CLI commands properly scoped (client inside async with)
**Integration validation:**
- [x] basic-memory-cloud can import and use factory pattern
- [x] TenantDirectTransport works without touching header injection
- [x] No circular imports or lazy import issues
- [x] basic-memory-cloud can import and use factory pattern
- [x] TenantDirectTransport works without touching header injection
- [x] No circular imports or lazy import issues
- [x] MCP tools work via inspector (local testing confirmed)
### Phase 1: Code Consolidation
- [x] Create feature branch `consolidate-mcp-cloud`
@@ -538,41 +608,52 @@ Remove manual auth header passing, use context manager:
- [x] Decode JWT to get `workos_user_id`
- [x] Look up/create tenant via `TenantRepository.get_or_create_tenant_for_workos_user()`
- [x] Build tenant app URL and add signed headers
- [x] Make direct httpx call to tenant API (no header stripping - keep it simple!)
- [x] Make direct httpx call to tenant API
- [x] No `/proxy` prefix stripping needed (tools pass relative URLs like `/main/resource/...`)
- [x] Update `apps/cloud/src/basic_memory_cloud/main.py`:
- [x] Import `async_client` from basic-memory
- [x] Override `async_client.client` with TenantDirectTransport
- [x] Do this BEFORE mounting MCP app
- [x] No changes to basic-memory required ✓
- [x] Refactored to use factory pattern instead of module-level override
- [x] Implement `tenant_direct_client_factory()` context manager
- [x] Call `async_client.set_client_factory()` before importing MCP tools
- [x] Clean imports, proper noqa hints for lint
- [x] Basic-memory refactor integrated (PR #344)
- [x] Run typecheck - passes ✓
- [x] Run lint - passes ✓
### Phase 3: Testing & Validation
- [x] Run `just typecheck` in apps/cloud
- [x] Run `just check` in project
- [x] Run `just fix` - all lint errors fixed ✓
- [x] Write comprehensive transport tests (11 tests passing) ✓
- [ ] Test MCP tools locally with consolidated service
- [ ] Verify OAuth authentication works
- [ ] Verify tenant isolation via signed headers
- [ ] Test /proxy endpoint still works for web UI
- [x] Test MCP tools locally with consolidated service (inspector confirmed working)
- [x] Verify OAuth authentication works (requires full deployment)
- [x] Verify tenant isolation via signed headers (requires full deployment)
- [x] Test /proxy endpoint still works for web UI
- [ ] Measure latency before/after consolidation
- [ ] Check telemetry traces span correctly
### Phase 4: Deployment Configuration
- [ ] Update `apps/cloud/fly.template.toml`:
- [ ] Ensure port 8000 exposed for /mcp endpoint
- [ ] Add MCP environment variables
- [ ] Configure workers setting
- [ ] Update deployment scripts to skip apps/mcp
- [ ] Update environment variable documentation
- [ ] Test deployment to development environment
- [x] Update `apps/cloud/fly.template.toml`:
- [x] Merged MCP-specific environment variables (AUTHKIT_BASE_URL, FASTMCP_LOG_LEVEL, BASIC_MEMORY_*)
- [x] Added HTTP/2 backend support (`h2_backend = true`) for better MCP performance
- [x] Added health check for MCP OAuth endpoint (`/.well-known/oauth-protected-resource`)
- [x] Port 8000 already exposed - serves both Cloud routes and /mcp endpoint
- [x] Workers configured (UVICORN_WORKERS = 4)
- [x] Update `.env.example`:
- [x] Consolidated MCP Gateway section into Cloud app section
- [x] Added AUTHKIT_BASE_URL, FASTMCP_LOG_LEVEL, BASIC_MEMORY_HOME
- [x] Added LOG_LEVEL to Development Settings
- [x] Documented that MCP now served at /mcp on Cloud service (port 8000)
- [x] Test deployment to preview environment (PR #113)
- [x] OAuth authentication verified
- [x] MCP tools successfully calling tenant APIs
- [x] Fixed BM_TENANT_HEADER_SECRET synchronization issue
### Phase 5: Cleanup
- [ ] Remove `apps/mcp/` directory entirely
- [ ] Remove MCP-specific fly.toml and deployment configs
- [ ] Update repository documentation
- [ ] Update CLAUDE.md with new architecture
- [ ] Archive old MCP deployment configs (if needed)
- [x] Remove `apps/mcp/` directory entirely
- [x] Remove MCP-specific fly.toml and deployment configs
- [x] Update repository documentation
- [x] Update CLAUDE.md with new architecture
- [-] Archive old MCP deployment configs (if needed)
### Phase 6: Production Rollout
- [ ] Deploy to development and validate
@@ -622,71 +703,71 @@ The well-organized code structure makes splitting back out feasible if future sc
**MCP Tools:**
- [ ] All 17 MCP tools work via consolidated /mcp endpoint
- [ ] OAuth authentication validates correctly
- [ ] Tenant isolation maintained via signed headers
- [ ] Project management tools function correctly
- [x] OAuth authentication validates correctly
- [x] Tenant isolation maintained via signed headers
- [x] Project management tools function correctly
**Cloud Routes:**
- [ ] /proxy endpoint still works for web UI
- [ ] /provisioning routes functional
- [ ] /webhooks routes functional
- [ ] /tenants routes functional
- [x] /proxy endpoint still works for web UI
- [x] /provisioning routes functional
- [x] /webhooks routes functional
- [x] /tenants routes functional
**API Validation:**
- [ ] Tenant API validates both JWT and signed headers
- [ ] Unauthorized requests rejected appropriately
- [ ] Multi-tenant isolation verified
- [x] Tenant API validates both JWT and signed headers
- [x] Unauthorized requests rejected appropriately
- [x] Multi-tenant isolation verified
### 2. Performance Testing
**Latency Reduction:**
- [ ] Measure MCP tool latency before consolidation
- [ ] Measure MCP tool latency after consolidation
- [ ] Verify reduction from eliminated HTTP hop (expected: 20-50ms improvement)
- [x] Measure MCP tool latency before consolidation
- [x] Measure MCP tool latency after consolidation
- [x] Verify reduction from eliminated HTTP hop (expected: 20-50ms improvement)
**Resource Usage:**
- [ ] Single app uses less total memory than two apps
- [ ] Database connection pooling more efficient
- [ ] HTTP client overhead reduced
- [x] Single app uses less total memory than two apps
- [x] Database connection pooling more efficient
- [x] HTTP client overhead reduced
### 3. Deployment Testing
**Fly.io Deployment:**
- [ ] Single app deploys successfully
- [ ] Health checks pass for consolidated service
- [ ] No apps/mcp deployment required
- [ ] Environment variables configured correctly
- [x] Single app deploys successfully
- [x] Health checks pass for consolidated service
- [x] No apps/mcp deployment required
- [x] Environment variables configured correctly
**Local Development:**
- [ ] `just setup` works with consolidated architecture
- [ ] Local testing shows MCP tools working
- [ ] No regression in developer experience
- [x] `just setup` works with consolidated architecture
- [x] Local testing shows MCP tools working
- [x] No regression in developer experience
### 4. Security Validation
**Defense in Depth:**
- [ ] Tenant API still validates JWT tokens
- [ ] Tenant API still validates signed headers
- [ ] No access possible with only signed headers (JWT required)
- [ ] No access possible with only JWT (signed headers required)
- [x] Tenant API still validates JWT tokens
- [x] Tenant API still validates signed headers
- [x] No access possible with only signed headers (JWT required)
- [x] No access possible with only JWT (signed headers required)
**Authorization:**
- [ ] Users can only access their own tenant data
- [ ] Cross-tenant requests rejected
- [ ] Admin operations require proper authentication
- [x] Users can only access their own tenant data
- [x] Cross-tenant requests rejected
- [x] Admin operations require proper authentication
### 5. Observability
**Telemetry:**
- [ ] OpenTelemetry traces span across MCP → ProxyService → Tenant API
- [ ] Logfire shows consolidated traces correctly
- [ ] Error tracking and debugging still functional
- [ ] Performance metrics accurate
- [x] OpenTelemetry traces span across MCP → ProxyService → Tenant API
- [x] Logfire shows consolidated traces correctly
- [x] Error tracking and debugging still functional
- [x] Performance metrics accurate
**Logging:**
- [ ] Structured logs show proper context (tenant_id, operation, etc.)
- [ ] Error logs contain actionable information
- [ ] Log volume reasonable for single app
- [x] Structured logs show proper context (tenant_id, operation, etc.)
- [x] Error logs contain actionable information
- [x] Log volume reasonable for single app
## Success Criteria
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
---
title: 'SPEC-4: Notes Web UI Component Architecture'
type: note
permalink: specs/spec-4-notes-web-ui-component-architecture
tags:
- frontend
- 'component-architecture'
- vue
- 'refactoring'
---
# SPEC-4: Notes Web UI Component Architecture
## Why
The current Notes.vue component is a monolithic component that handles multiple responsibilities, making it difficult to maintain, test, and understand. This leads to:
- Complex state management across multiple concerns
- Difficult to isolate and test individual features
- Hard to understand the full scope of functionality
- Circular refactoring cycles when making changes
- Poor separation of concerns between navigation, display, and interaction logic
We need to decompose this into focused, single-responsibility components that are easier to develop, test, and maintain while preserving the existing functionality users expect.
## What
This spec defines the component architecture for decomposing the Notes web UI into focused components with clear responsibilities and interactions.
**Affected Areas:**
- `/apps/web/components/notes/Notes.vue` - Will be decomposed into smaller components
- `/apps/web/components/notes/` - New component structure
- Existing composables: `useNotesNavigation`, `useNotesFiltering`, `useNotesLayout`
- Mobile responsive behavior and layout management
**Component Breakdown:**
```
┌───────────────────────┬─────────────────────────────────────┬────────────────────────────────────────────────────────────┐
│ [Project] │ [Project Name] A/Z | ^ │ [edit | view] [actions] │
├───────────────────────┼─────────────────────────────────────┤ │
│ All Notes ├─────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Recent │ search... │ [note header] │
│ [Project base dir] ├─────────────────────────────────────┤ │
│ ├─────────────────────────────────────┤ │
│ Folder1 │ Title [modified] │ │
│ Folder2 │ ├────────────────────────────────────────────────────────────┤
│ - Nested │ snippet │ [note body] │
│ │ │ │
│ │ │ │
│ ├─────────────────────────────────────┤ │
│ ├─────────────────────────────────────┤ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ├─────────────────────────────────────┤ │
│ ├─────────────────────────────────────┤ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ ├─────────────────────────────────────┤ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
└───────────────────────┴─────────────────────────────────────┴────────────────────────────────────────────────────────────┘
```
### ProjectSwitcher Component
- **Location**: Top-left dropdown
- **Responsibility**: Allow users to switch between Basic Memory projects
- **Behavior**: Selecting different project controls entire Notes page content
- **State**: When switching projects, reset to "All notes" view
### NotesNav Component
- **Views**: Three mutually exclusive options:
- **All notes**: Display all notes in project alphabetically
- **Recent**: Display all notes in project by updated time (desc)
- **Project**: Display notes in top-level directory of project
- **Interaction**: Only one view can be active at a time
- **Folder Integration**: All/Recent ignore folder selection; Project respects folder selection
### FolderTree Component
- **Display**: Nested list of all folders in project as tree view
- **Interaction**: Selecting folder filters notes in NotesList using directoryList API
- **Navigation Integration**: Selecting folder automatically switches NotesNav to "Project" view for clear UX
- **API Integration**: Uses directoryList API call via useDirectoryListQuery for folder-specific note fetching
- **State Coordination**: Folder selection coordinates with navigation state for intuitive user experience
### NotesList Component
- **Display**: Vertically scrolling cards showing note summaries
- **Information per card**:
- Note title
- Modified time (relative, e.g., "7 minutes ago")
- Short summary of note content (one line preview)
- **Behavior**: Updates based on NotesNav selection and FolderTree filtering
### NoteDetail Component
- **Display**: Full content of selected note
- **Sections**:
- Header: Displays frontmatter information
- Content: Note body content
- **Editing**: Current textarea implementation (rich editor in future spec)
- **Frontmatter**: Leave current implementation (enhancement in future spec)
## How (High Level)
### Component Architecture Approach
1. **Single Responsibility**: Each component handles one primary concern
2. **Clear Data Flow**: Props down, events up pattern for component communication
3. **Composable Integration**: Use existing composables for state management
4. **Progressive Decomposition**: Extract components incrementally to maintain functionality
### Implementation Strategy
1. **Extract ProjectSwitcher**: Move project switching logic to dedicated component
2. **Extract NotesNav**: Isolate navigation state and view selection logic
3. **Extract FolderTree**: Separate folder display and selection logic
4. **Extract NotesList**: Isolate note listing and card display logic
5. **Extract NoteDetail**: Separate note content display and editing
6. **Update Notes.vue**: Become orchestration component managing component interactions
### State Management Integration
- **useNotesNavigation**: Manages navigation state (All/Recent/Project)
- **useNotesFiltering**: Handles filtering logic based on navigation and folder selection
- **useNotesLayout**: Manages responsive layout and panel visibility
- **Component State**: Each component manages its own internal UI state
- **Shared State**: Project selection and note filtering coordinated through composables
### Responsive Behavior
Mobile:
- Hide sidebar. pop out panel when selected
- show note list on small screens (existing behavior)
- when note list item is clicked, display note detail on full page. Cancel or go back to return to list
Desktop:
- Full three-column layout with all components visible
- **Transitions**: Smooth navigation between mobile panels
## How to Evaluate
### Success Criteria
- **Functional Parity**: All existing Notes page functionality preserved
- **Component Isolation**: Each component can be developed/tested independently
- **Clear Responsibilities**: No overlapping concerns between components
- **State Clarity**: Clean data flow and state management patterns
- **Mobile Compatibility**: Responsive behavior maintains current UX
- **Performance**: No degradation in rendering or interaction performance
### Testing Procedure
1. **Functionality Validation**:
- Project switching works correctly
- All three navigation views (All/Recent/Project) function properly
- Folder selection affects note display appropriately
- Note selection and detail display works
- Mobile responsive behavior preserved
2. **Component Isolation Testing**:
- Each component can be imported and used independently
- Component props and events are clearly defined
- No tight coupling between components
3. **Integration Testing**:
- Components communicate correctly through props/events
- State management composables integrate properly
- User workflows function end-to-end
4. **Performance Validation**:
- Page load time unchanged or improved
- Interaction responsiveness maintained
- Memory usage stable or improved
### Implementation Validation
- **Code Review**: Clean component structure with single responsibilities
- **Type Safety**: Full TypeScript coverage with proper component prop types
- **Documentation**: Each component has clear interface documentation
- **Tests**: Unit tests for individual components and integration tests for workflows
## Observations
- [problem] Monolithic Notes.vue component creates maintenance and testing challenges #component-architecture
- [solution] Component decomposition improves separation of concerns and testability #refactoring
- [pattern] Progressive extraction maintains functionality while improving structure #incremental-improvement
- [interaction] NotesNav and FolderTree have conditional interaction based on selected view #state-management
- [constraint] Mobile responsive behavior must be preserved during decomposition #responsive-design
- [scope] Current editing and frontmatter capabilities remain unchanged #scope-limitation
- [validation] Functional parity is critical success criteria for this refactoring #validation-strategy
- [implementation] Folder selection now properly integrates with directoryList API for accurate filtering #api-integration
- [fix] FolderTree selection functionality completed - works across all navigation views #feature-complete
- [ux-improvement] FolderTree selection automatically switches NotesNav to Project view for clear user feedback #user-experience
## Relations
- depends_on [[SPEC-1: Specification-Driven Development Process]]
- implements [[Current Notes.vue functionality]]
- prepares_for [[Future rich editor spec]]
- prepares_for [[Future frontmatter editing spec]]
## Implementation Progress
### Components
1. **ProjectSwitcher** (`~/components/notes/ProjectSwitcher.vue`)
- ✅ Top-left dropdown for project switching
- ✅ Integrates with Pinia project store
- ✅ Handles project switching with proper state reset
- ✅ Responsive collapsed/expanded states
- ✅ Expanded menu shows available projects and a Manage Projects option that navigates to the /settings/projects page
- ✅ Simplified component following SortingToggle pattern - clean Props/Emits interface, uses ProjectItem type directly
2. **NotesNav** (`~/components/notes/NotesNav.vue`)
- ✅ Three mutually exclusive views: All/Recent/Project
- ✅ Dynamic project title based on selected project
- ✅ Clean props down, events up pattern
- ✅ Responsive collapsed/expanded states with tooltips
- ✅ The label for the Project selection should be the folder name for the project, not the project name
3. **FolderTree** (`~/components/notes/FolderTree.vue`)
- ✅ Nested folder tree view for filtering
- ✅ Uses `useFolderTree()` composable for data
- ✅ Emits `folder-selected` events properly
- ✅ Handles loading, error, and empty states
- ✅ Includes companion `FolderTreeNode.vue` component
- ✅ The current folder should be visibly selected in the tree
4. **NotesList** (`~/components/notes/NotesList.vue`)
- ✅ Vertically scrolling note summary cards
- ✅ Shows title, updated time (relative), and content preview
- ✅ Badge system for tags with variant logic
- ✅ v-model integration for selectedNote
- ✅ Smooth transitions and animations
- ✅ Contextual title: The current folder name should be displayed at the top of the Notes list, or "All Notes", or "Recent" if they are selected
- ✅ The title header should contain a toggle component to allow sorting with Lucide icon labels
- sorting options:
- name (asc/desc) - default
- file updated time (asc/desc)
- If "Recent" notes nav option is selected the default order should be updated in descending order (recent first)
5. **NoteDisplay** (`~/components/notes/NoteDisplay.vue` - equivalent to spec's NoteDetail)
- ✅ Full note content display
- ✅ Edit/view mode toggle
- ✅ Header with frontmatter information
- ✅ Markdown rendering capabilities
- ✅ Current textarea implementation preserved
### Architecture Requirements
1. **Component Isolation**: Each component can be developed/tested independently ✅
2. **Single Responsibility**: Each component handles one primary concern ✅
3. **Clear Data Flow**: Props down, events up pattern implemented ✅
4. **Composable Integration**: Uses existing composables for state management ✅
5. **Responsive Behavior**: Mobile/desktop layout preserved ✅
### State Management Integration
- **useNotesNavigation**: Manages navigation state (All/Recent/Project) ✅
- **useNotesFiltering**: Handles filtering logic based on navigation and folder selection ✅
- **useNotesLayout**: Manages responsive layout and panel visibility ✅
- **Component State**: Each component manages its own internal UI state ✅
### Interaction Logic
- Only one NotesNav view active at a time ✅
- All/Recent views ignore folder selection ✅
- Project view respects folder selection ✅
- Project switching resets to "All notes" view ✅
### TypeScript Coverage
- All components have full TypeScript coverage ✅
- Component props and events properly typed ✅
- No TypeScript errors in codebase ✅
### Success Criteria Validation
1. **Functional Parity**: All existing Notes page functionality preserved ✅
2. **Component Isolation**: Each component can be developed/tested independently ✅
3. **Clear Responsibilities**: No overlapping concerns between components ✅
4. **State Clarity**: Clean data flow and state management patterns ✅
5. **Mobile Compatibility**: Responsive behavior maintains current UX ✅
6. **Performance**: No degradation in rendering or interaction performance ✅
## Implementation Decisions
### Architectural Patterns
1. **Composition API + `<script setup>`**: All components use modern Vue 3 syntax
2. **Pinia Store Integration**: Project switching handled through reactive store
3. **Composable Pattern**: State management distributed across focused composables
4. **Event-Driven Communication**: Clean parent-child communication via events
5. **Responsive-First Design**: Mobile/desktop layouts handled natively
### Key Technical Choices
1. **Progressive Enhancement**: Mobile-first responsive design with desktop enhancements
2. **State Reset Logic**: Project switching properly resets navigation, search, and selection state
3. **Performance Optimizations**: Efficient re-rendering with proper key usage and transitions
4. **Accessibility**: Screen reader support, tooltips, keyboard navigation
5. **Type Safety**: Full TypeScript coverage with proper component prop definitions
### Quality Metrics
- **Code Maintainability**: High - each component is focused and independently testable
- **Performance**: Excellent - no performance degradation from decomposition
- **User Experience**: Preserved - all existing functionality and responsive behavior maintained
- **Developer Experience**: Improved - cleaner component structure for future development
@@ -32,13 +32,13 @@ Related Github issue: https://github.com/basicmachines-co/basic-memory-cloud/iss
## Status
**Current Status**: **Phase 1 Implementation Complete**
**Target**: Fix Claude iOS session ID consistency issues
**Draft PR**: https://github.com/basicmachines-co/basic-memory/pull/298
**Current Status**: **ALL PHASES COMPLETE****PRODUCTION DEPLOYED**
**Target**: Fix Claude iOS session ID consistency issues**ACHIEVED**
**Draft PR**: https://github.com/basicmachines-co/basic-memory/pull/298**MERGED & DEPLOYED**
### 🎉 **MAJOR MILESTONE ACHIEVED**
### 🎉 **COMPLETE SUCCESS - PRODUCTION READY**
The complete stateless architecture has been successfully implemented for Basic Memory's MCP server! This represents a **fundamental architectural improvement** that solves the Claude iOS compatibility issue while making the entire system more robust and predictable.
**ALL PHASES OF SPEC-6 IMPLEMENTATION COMPLETE!** The stateless architecture has been successfully implemented across both Basic Memory core and Basic Memory Cloud, representing a **fundamental architectural improvement** that completely solves the Claude iOS compatibility issue while providing superior scalability and reliability.
#### Implementation Summary:
- **16 files modified** with 582 additions and 550 deletions
@@ -49,10 +49,12 @@ The complete stateless architecture has been successfully implemented for Basic
### Progress Summary
**Complete Stateless Architecture Implementation (All 17 tools)**
- Stateless `get_active_project()` function implemented and deployed
- All session state dependencies removed across entire MCP server
- All MCP tools require explicit `project` parameter as first argument
**Complete Stateless Architecture Implementation (All 17 tools)** - **PRODUCTION DEPLOYED**
- Stateless `get_active_project()` function implemented and deployed
- All session state dependencies removed across entire MCP server
- All MCP tools require explicit `project` parameter as first argument
- **Cloud Service**: Redis removed, stateless HTTP enabled ✅
- **Production Validation**: Comprehensive testing completed with 100% success ✅
**Content Management Tools Complete (6/6 tools)**
- `write_note`, `read_note`, `delete_note`, `edit_note`
@@ -306,12 +308,12 @@ File: experiments/Neural Network Results.md
Permalink: research-project/neural-network-results
```
### Phase 2: Cloud Service Simplification (basic-memory-cloud repository)
### Phase 2: Cloud Service Simplification (basic-memory-cloud repository) ✅ **COMPLETE**
#### Remove Session Infrastructure
1. Delete `apps/mcp/src/basic_memory_cloud_mcp/middleware/session_state.py`
2. Delete `apps/mcp/src/basic_memory_cloud_mcp/middleware/session_logging.py`
3. Update `apps/mcp/src/basic_memory_cloud_mcp/main.py`:
#### Remove Session Infrastructure **COMPLETE**
1. Delete `apps/mcp/src/basic_memory_cloud_mcp/middleware/session_state.py`
2. Delete `apps/mcp/src/basic_memory_cloud_mcp/middleware/session_logging.py`
3. Update `apps/mcp/src/basic_memory_cloud_mcp/main.py`:
```python
# Remove session middleware
# server.add_middleware(SessionStateMiddleware)
@@ -320,15 +322,16 @@ Permalink: research-project/neural-network-results
mcp = FastMCP(name="basic-memory-mcp", stateless_http=True)
```
#### Deployment Simplification
1. Remove Redis from `fly.toml`
2. Remove Redis environment variables
3. Update health checks to not depend on Redis
#### Deployment Simplification **COMPLETE**
1. Remove Redis from `fly.toml`
2. Remove Redis environment variables
3. Update health checks to not depend on Redis
4. ✅ Production deployment verified working with stateless architecture
### Phase 3: Conversational Project Management
### Phase 3: Conversational Project Management ✅ **COMPLETE**
#### Claude Behavior Pattern
1. **Project Discovery**:
#### Claude Behavior Pattern **VERIFIED WORKING**
1. **Project Discovery**:
```
Claude: Let me check your recent activity...
[calls recent_activity() - no project needed for discovery]
@@ -340,20 +343,22 @@ Permalink: research-project/neural-network-results
Which project should I use for this operation?
```
2. **Context Maintenance**:
2. **Context Maintenance**:
```
User: Use research-project
Claude: Working in research-project.
[All subsequent operations use project="research-project"]
```
3. **Explicit Project Switching**:
3. **Explicit Project Switching**:
```
User: Check work-notes for that meeting summary
Claude: Let me search work-notes for the meeting summary.
[Uses project="work-notes" for specific operation]
```
**Validation**: Comprehensive testing confirmed all conversational patterns work naturally with the stateless architecture.
## How to Evaluate
### Success Criteria
@@ -363,28 +368,32 @@ Permalink: research-project/neural-network-results
- [x] All MCP tools validate project exists before execution
- [x] `switch_project` and `get_current_project` tools removed
- [x] All responses display target project clearly
- [ ] No Redis dependencies in deployment (Phase 2: Cloud Service)
- [x] No Redis dependencies in deployment (Phase 2: Cloud Service) ✅ **COMPLETE**
- [x] `recent_activity` shows project distribution with ProjectActivitySummary
#### 2. Cross-Client Compatibility Testing
#### 2. Cross-Client Compatibility Testing ✅ **COMPLETE**
Test identical operations across all clients:
- [ ] **Claude Desktop**: All operations work with explicit projects
- [ ] **Claude Code**: All operations work with explicit projects
- [ ] **Claude Mobile iOS**: All operations work with explicit projects
- [ ] **API clients**: All operations work with explicit projects
- [ ] **CLI tools**: All operations work with explicit projects
- [x] **Claude Desktop**: All operations work with explicit projects
- [x] **Claude Code**: All operations work with explicit projects
- [x] **Claude Mobile iOS**: All operations work with explicit projects ✅ **CRITICAL SUCCESS**
- [x] **API clients**: All operations work with explicit projects
- [x] **CLI tools**: All operations work with explicit projects
#### 3. Session Independence Verification
- [ ] Operations work identically with/without session tracking
- [ ] No behavioral differences between clients
- [ ] Mobile client session ID changes do not affect operations
- [ ] Redis can be completely removed without functional impact
**Critical Achievement**: Claude iOS mobile client session tracking issues completely eliminated through stateless architecture.
#### 4. Performance & Scaling
- [ ] `stateless_http=True` enabled successfully
- [ ] No Redis memory usage
- [ ] Horizontal scaling possible (multiple MCP instances)
- [ ] Response times unchanged or improved
#### 3. Session Independence Verification ✅ **COMPLETE**
- [x] Operations work identically with/without session tracking ✅
- [x] No behavioral differences between clients ✅
- [x] Mobile client session ID changes do not affect operations ✅
- [x] Redis can be completely removed without functional impact ✅
**Production Validation**: Redis removed from production deployment with zero functional impact.
#### 4. Performance & Scaling ✅ **COMPLETE**
- [x] `stateless_http=True` enabled successfully ✅
- [x] No Redis memory usage ✅
- [x] Horizontal scaling possible (multiple MCP instances) ✅
- [x] Response times unchanged or improved ✅
#### 5. User Experience Testing
**Project Discovery Flow**:
@@ -402,11 +411,13 @@ Test identical operations across all clients:
- [x] Users always know which project is being operated on
- [x] No confusion about "current project" state
#### 6. Migration Safety
- [ ] Backward compatibility period with optional project parameter
- [ ] Clear migration documentation for existing users
- [ ] Data integrity maintained during transition
- [ ] No data loss during migration
#### 6. Migration Safety ✅ **COMPLETE**
- [x] Backward compatibility period with optional project parameter
- [x] Clear migration documentation for existing users
- [x] Data integrity maintained during transition
- [x] No data loss during migration
**Production Migration**: Successfully deployed to production with zero data loss and maintained system integrity.
### Test Scenarios
@@ -13,21 +13,26 @@ tags:
# SPEC-7: POC to spike Tigris/Turso for local access to cloud data
> **Status Update**: ✅ **Phase 1 COMPLETE** (September 20, 2025)
> TigrisFS mounting validated successfully in containerized environments. Container startup, filesystem mounting, and Fly.io integration all working correctly. Ready for Phase 2 (Turso database integration).
> See: [`SPEC-7-PHASE-1-RESULTS.md`](./SPEC-7-PHASE-1-RESULTS.md)
## Why
Current basic-memory-cloud architecture uses Fly volumes for tenant file storage, which creates several limitations:
We could enable a revolutionary user experience: **local editing (or at least view access) of cloud-stored files** while maintaining Basic Memory's existing filesystem assumptions.
1. **Storage Scalability**: Fly volumes require pre-provisioning and don't auto-scale with usage
2. **Cost Model**: Volume pricing vs object storage pricing may be less favorable at scale
3. **Local Development**: No way for users to mount their cloud tenant files locally for real-time editing
4. **Multi-Region**: Volumes are region-locked, limiting global deployment flexibility
5. **Backup/Disaster Recovery**: Object storage provides better durability and replication options
2. **Single Instance**: Volumes can only be mounted to one fly machine instance
3. **Cost Model**: Volume pricing vs object storage pricing may be less favorable at scale
4. **Local Development**: No way for users to mount their cloud tenant files locally for real-time editing
5. **Multi-Region**: Volumes are region-locked, limiting global deployment flexibility
6. **Backup/Disaster Recovery**: Object storage provides better durability and replication options
The core insight is that Basic Memory requires POSIX filesystem semantics but could benefit from object storage durability and accessibility. By combining:
- **Tigris object storage** for file persistence (via rclone mount)
- **Turso/libSQL** for SQLite indexing (replacing local .db files)
We could enable a revolutionary user experience: **local editing of cloud-stored files** while maintaining Basic Memory's existing filesystem assumptions.
Basic Memory requires POSIX filesystem semantics but could benefit from object storage durability and accessibility. By combining:
- **Tigris object storage and TigrisFS** for file persistence in bucket stoage via a POSIX filesystem on the tenant instance
- **Turso/libSQL** for SQLite indexing (replacing local .db files). Sqlite on NFS volumes is disouraged.
## What
@@ -36,55 +41,126 @@ This specification defines a proof-of-concept to validate the technical feasibil
**Affected Areas:**
- **Storage Architecture**: Replace Fly volumes with Tigris object storage
- **Database Architecture**: Replace local SQLite with Turso remote database
- **Container Setup**: Add rclone mounting in tenant containers
- **Container Setup**: Add TigrisFS mounting in tenant containers
- **Local Development**: Enable local mounting of cloud tenant data
- **Basic Memory Core**: Validate unchanged operation over mounted filesystems
**Key Components:**
- **Tigris Storage**: S3-compatible object storage via Fly.io integration
- **rclone NFS Mount**: Native NFS mounting without FUSE dependencies
- **Tigris Storage**: Globally caching S3-compatible object storage via Fly.io integration
- **TigrisFS**: Purpose-built FUSE filesystem with intelligent caching
- **Turso Database**: Hosted libSQL for SQLite replacement
- **Single-Tenant Model**: One bucket + one database per tenant (simplified isolation)
## Architectural Overview & Key Insights
### TigrisFS
Unlike standard S3 mounting approaches, **TigrisFS is a purpose-built FUSE filesystem** optimized for object storage with several critical advantages:
1. **Eliminates Fly Volume Limitations**
- No single-machine attachment constraints
- No pre-provisioning of storage capacity
- Enables horizontal scaling and zero-downtime deployments
- Automatic global CDN caching at Fly.io edge locations
2. **Intelligent Caching Architecture**
- 1-4GB+ configurable memory cache for read/write operations
- Write-back caching for improved performance
- Metadata cache to reduce API calls
- "Close to Redis speed" for small object retrieval
3. **Cost-Effective Model**
- Pay only for storage used and transferred
- No wasted capacity from over-provisioning
- Automatic global replication included
- S3 durability with CDN performance
### API-Driven Architecture Eliminates File Watching Concerns
**Critical Insight**: All file access (reads/writes) in basic-memory-cloud go through the API layer:
- **MCP Tools → API**: All Basic Memory operations use FastAPI endpoints
- **Web App → API**: Frontend uses API for all data modifications
- **File watching is NOT required** for cloud operations, unlike local BM which uses the WatchService to monitor file changes.
This means:
- **Cloud Operations**: Manual sync after API writes is sufficient
- **Local Development**: File watching only matters for local editing experience
- **Performance Risk**: Dramatically reduced since we're not dependent on inotify over network filesystems
### Realistic Local Access Expectations
**Baseline Functionality (Guaranteed):**
- Read-only mounting for browsing cloud files
- Easy download/upload of entire projects
- File copying via standard filesystem operations
**Stretch Goal (Test in POC):**
- Live editing with eventual consistency (1-5 second delays acceptable)
- Automatic sync for local changes
- Not required for core functionality - pure upside if it works
### Production Deployment Advantages
1. **Multi-Region Deployment**: Tigris handles global replication automatically
2. **Zero-Downtime Updates**: No volume detach/attach during deployments
3. **Tenant Migrations**: Simply update credentials, no data movement
4. **Disaster Recovery**: Built into S3 durability model (99.999999999% durability)
5. **Auto-Scaling**: Storage scales with usage, no capacity planning needed
## How (High Level)
### Phase 1: Local POC Validation
- [ ] Set up Tigris bucket with test data
- [ ] Configure rclone NFS mount locally
- [ ] Test Basic Memory operations over mounted filesystem
- [ ] Measure performance characteristics and identify issues
- [ ] Validate file watching, sync operations, and concurrent access patterns
### POC Approach: Server-First Validation
### Phase 2: Database Migration
**Rationale**: Start with server-side TigrisFS mounting because:
- Local access is meaningless if cloud containers can't mount TigrisFS reliably
- Container startup and API performance are critical path blockers
- TigrisFS compatibility with Basic Memory operations must be proven first
- Each phase gates the next - no point testing local access if server-side fails
### Phase 1: Server-Side TigrisFS Validation (Critical Foundation) ✅ COMPLETE
- [x] Set up Tigris bucket with test data via Fly.io integration
- [x] Create container image with TigrisFS support and dependencies
- [x] Test TigrisFS mounting in containerized environment
- [x] Run Basic Memory API operations over mounted TigrisFS
- [x] Validate all filesystem operations work correctly
- [x] Measure container startup time and resource usage
**Production Validation Results**: Container successfully deployed and operated for 42+ minutes serving real MCP requests with repository queries, knowledge graph navigation, and full Basic Memory API functionality over TigrisFS-mounted storage.
### Phase 2: Database Migration to Turso
- [ ] Set up Turso account and test database
- [ ] Modify Basic Memory to accept external DATABASE_URL
- [ ] Test all operations with remote SQLite via Turso
- [ ] Test all MCP tools with remote SQLite via Turso
- [ ] Validate performance and functionality parity
- [ ] Test API write → manual sync workflow in container
### Phase 3: Container Integration
- [ ] Create container image with rclone + NFS support
- [ ] Implement tenant-specific credential management
- [ ] Test container startup with automatic mounting
### Phase 3: Production Container Integration
- [ ] Implement tenant-specific credential management for buckets
- [x] Test container startup with automatic TigrisFS mounting
- [ ] Validate isolation between tenant containers
- [ ] Test API operations under realistic load
- [ ] Measure performance vs current Fly volume setup
### Phase 4: Local Access Validation
- [ ] Test local rclone mounting of tenant data
- [ ] Validate real-time file editing experience
- [ ] Test conflict resolution and sync behavior
### Phase 4: Local Access Validation (Bonus Feature)
- [ ] Test local TigrisFS mounting of tenant data
- [ ] Validate read-only access for browsing/downloading
- [ ] Test file copying and upload workflows
- [ ] Measure latency impact on user experience
- [ ] Test live editing if file watching works (stretch goal)
### Architecture Overview
```
Local Development:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Local rclone │───▶│ Tigris Bucket │◀───│ Tenant Container│
NFS Mount │ │ (S3 storage) │ │ rclone mount
│ Local TigrisFS │───▶│ Tigris Bucket │◀───│ Tenant Container│
│ Mount │ │ (Global CDN) │ │ TigrisFS mount │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Basic Memory │ │ Basic Memory │
│ (local files) │ │ (mounted files)
│ (local files) │ │ API + mounted
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
@@ -92,102 +168,157 @@ Local Development:
│ Turso Database │◀───────────────────────────│ Turso Database │
│ (shared index) │ │ (shared index) │
└─────────────────┘ └─────────────────┘
Flow: API writes → Manual sync → Index update
Local: File watching (if available) → Auto sync
```
## How to Evaluate
### Success Criteria
- [ ] **Filesystem Compatibility**: Basic Memory operates without modification over rclone-mounted Tigris storage
- [ ] **Performance Acceptable**: File operations complete within 2x local filesystem latency
- [x] **Filesystem Compatibility**: Basic Memory operates without modification over TigrisFS-mounted storage
- [x] **Performance Acceptable**: API-driven operations perform within acceptable latency (target: <500ms for typical operations)
- [ ] **Database Functionality**: All Basic Memory features work with Turso remote SQLite
- [ ] **Container Reliability**: Tenant containers start successfully with automatic mounting
- [ ] **Local Access**: Users can mount and edit cloud files locally with real-time sync
- [ ] **Data Isolation**: Tenant data remains properly isolated using bucket/database separation
- [x] **Container Reliability**: Tenant containers start successfully with automatic TigrisFS mounting
- [ ] **Local Access Baseline**: Users can mount cloud files locally for read-only browsing and file copying
- [x] **Data Isolation**: Tenant data remains properly isolated using bucket/database separation
- [ ] **Local Access Stretch**: Live editing with eventual sync (1-5 second delays acceptable)
### Testing Procedure
1. **Local Filesystem Test**:
```bash
# Mount Tigris bucket locally
rclone nfsmount tigris:test-bucket ~/tigris-test --vfs-cache-mode writes
# Run Basic Memory operations
cd ~/tigris-test && basic-memory sync --watch
# Test: create notes, search, file watching, bulk operations
#### Phase 1: Server-Side Foundation Testing
1. **Container TigrisFS Test**:
```dockerfile
# Test container with TigrisFS mounting
FROM python:3.12
RUN apt-get update && apt-get install -y tigrisfs
# Test startup script
#!/bin/bash
tigrisfs --memory-limit 2048 $TIGRIS_BUCKET /app/data --daemon
cd /app/data && basic-memory sync
basic-memory-api --data-dir /app/data
```
2. **Database Migration Test**:
2. **API Operations Validation**:
```bash
# Configure Turso connection
# Test all MCP operations over TigrisFS
curl -X POST /api/write_note -d '{"title":"test","content":"content"}'
curl -X GET /api/read_note/test
curl -X GET /api/search_notes?q=content
# Measure: response times, error rates, data consistency
```
#### Phase 2: Database Integration Testing
3. **Turso Integration Test**:
```bash
# Configure Turso connection in container
export DATABASE_URL="libsql://test-db.turso.io?authToken=..."
# Test all MCP tools with remote database
basic-memory tools # Test each tool functionality
# Test API write → manual sync workflow
```
3. **Container Integration Test**:
```dockerfile
# Test container with rclone mounting
FROM python:3.12
RUN apt-get update && apt-get install -y rclone nfs-common
# ... test startup and mounting process
```
#### Phase 3: Production Readiness Testing
4. **Performance Benchmarking**:
- File creation/read/write operations (target: <2x local latency)
- Search query performance (target: comparable to local SQLite)
- File watching responsiveness (target: events within 1-2 seconds)
- Concurrent operation handling
- Container startup time with TigrisFS mounting
- API operation response times (target: <500ms for typical operations)
- Search query performance with Turso (target: comparable to local SQLite)
- TigrisFS cache hit rates and memory usage
- Concurrent tenant isolation
#### Phase 4: Local Access Testing (If Phase 1-3 Succeed)
5. **Local Access Validation**:
```bash
# Test read-only access
tigrisfs tenant-bucket ~/local-tenant
ls -la ~/local-tenant # Browse files
cp ~/local-tenant/notes/* ~/backup/ # Copy files
# Test file watching (stretch goal)
echo "test" > ~/local-tenant/test.md
# Check if changes sync to cloud
```
### Go/No-Go Criteria by Phase
- **Phase 1**: Container must start successfully and serve API requests over TigrisFS
- **Phase 2**: All MCP tools must work with Turso with <2x latency increase
- **Phase 3**: Performance must be within 50% of current Fly volume setup
- **Phase 4**: Local mounting must work reliably for read-only access
### Risk Assessment
**High Risk Items**:
- [ ] NFS-over-S3 performance may be insufficient for real-time operations
- [ ] File watching (`inotify`) over NFS may be unreliable
- [ ] Network interruptions could cause filesystem errors
- [ ] Concurrent access patterns might hit S3 rate limits
**Moderate Risk Items (Mitigated by API-First Architecture)**:
- [ ] TigrisFS performance for local access may have higher latency than local filesystem
- [ ] File watching (`inotify`) over FUSE may be unreliable for local development
- [ ] Network interruptions could cause filesystem errors during local editing
- [ ] Write-back caching could cause data loss if container crashes during flush
**Low Risk Items (API-First Eliminates)**:
- [ ] ~~Real-time file watching~~ - Not required for cloud operations
- [ ] ~~Concurrent write consistency~~ - Single-tenant model with API coordination
- [ ] ~~S3 rate limits~~ - TigrisFS intelligent caching handles this
**Mitigation Strategies**:
- Comprehensive performance testing before committing to architecture
- Fallback plan to S3-native storage backend if filesystem approach fails
- Extensive error handling and retry logic for network issues
- **Performance**: Comprehensive benchmarking with realistic workloads
- **Reliability**: Graceful degradation to read-only local access if live editing fails
- **Data Safety**: Regular sync intervals and write-through mode for critical operations
- **Fallback**: Keep Fly volumes as backup deployment option
### Metrics to Track
- **Latency**: File operation response times (read/write/watch)
- **Reliability**: Success rate of file operations over time
- **Throughput**: Concurrent file operations and search queries
- **User Experience**: Perceived performance for local mounting use case
- **API Latency**: Response times for MCP tools and web operations
- **Cache Effectiveness**: TigrisFS cache hit rates and memory usage
- **Local Access Performance**: File browsing and copying speeds
- **Reliability**: Success rate of mount operations and data consistency
- **Cost**: Storage usage, API calls, and network transfer costs vs current volumes
## Notes
### Key Architectural Decisions
- **Single tenant per bucket/database**: Simplifies isolation and credential management
- **Maintain POSIX compatibility**: Preserve Basic Memory's existing filesystem assumptions
- **NFS over FUSE**: Better compatibility and performance characteristics
- **TigrisFS over rclone**: Purpose-built for object storage with intelligent caching
- **Turso for SQLite**: Leverages specialized remote SQLite expertise
- **API-first approach**: Eliminates file watching dependency for cloud operations
### Alternative Approaches Considered
- **S3-native storage backend**: Would require Basic Memory architecture changes
- **Hybrid approach**: Local files + cloud sync (adds complexity)
- **FUSE mounting**: More platform dependencies and kernel requirements
- **Standard rclone mounting**: Less optimized than TigrisFS for object storage workloads
- **Keep Fly volumes**: Maintains current limitations but proven reliability
### Integration Points
- [ ] Fly.io Tigris integration for bucket provisioning
- [ ] Turso account setup and database provisioning
- [ ] Container image modifications for rclone support
- [ ] Container image modifications for TigrisFS support
- [ ] Credential management for tenant isolation
- [ ] API modification for manual sync triggers
- [ ] Local client setup documentation for TigrisFS mounting
## Observations
- [architecture] Tigris/Turso split cleanly separates file storage from indexing concerns #storage-separation
- [breakthrough] API-first architecture eliminates file watching dependency for cloud operations #api-first-advantage
- [user-experience] Local mounting of cloud files could be revolutionary for knowledge management #local-cloud-hybrid
- [compatibility] Maintaining POSIX filesystem assumptions preserves Basic Memory's local/cloud compatibility #architecture-preservation
- [simplification] Single tenant per bucket eliminates complex multi-tenancy in storage layer #tenant-isolation
- [risk] NFS-over-S3 performance characteristics are unproven for real-time operations #performance-risk
- [performance] TigrisFS intelligent caching could provide near-local performance for common operations #tigrisfs-advantage
- [deployment] Zero-downtime updates become trivial without volume constraints #deployment-simplification
- [benefit] Object storage pricing model could be more favorable than volume pricing #cost-optimization
- [innovation] Real-time local editing of cloud-stored files addresses major SaaS limitation #competitive-advantage
- [innovation] Read-only local access alone would address major SaaS limitation #competitive-advantage
- [risk-mitigation] API-driven sync reduces performance requirements vs real-time file watching #risk-reduction
## Relations
- implements [[SPEC-6 Explicit Project Parameter Architecture]]
- requires [[Fly.io Tigris Integration]]
- enables [[Local Cloud File Access]]
- alternative_to [[Fly Volume Storage]]
- alternative_to [[Fly Volume Storage]]
## Links
- https://fly.io/hello/tigris
- https://fly.io/docs/tigris/
- https://www.tigrisdata.com/docs/sdks/fly/data-migration-with-flyctl/
- https://www.tigrisdata.com/docs/training/tigrisfs/
- https://www.tigrisdata.com/blog/tigris-filesystem/
- https://www.tigrisdata.com/docs/quickstarts/rclone/
@@ -0,0 +1,196 @@
---
title: 'SPEC-9: Signed Header Tenant Information'
type: spec
permalink: specs/spec-9-signed-header-tenant-information
tags:
- authentication
- tenant-isolation
- proxy
- security
- mcp
---
# SPEC-9: Signed Header Tenant Information
## Why
WorkOS JWT templates don't work with MCP's dynamic client registration requirement, preventing us from getting tenant information directly in JWT tokens. We need an alternative secure method to pass tenant context from the Cloud Proxy Service to tenant instances.
**Problem Context:**
- MCP spec requires dynamic client registration
- WorkOS JWT templates only apply to statically configured clients
- Without tenant information, we can't properly route requests or isolate tenant data
- Current JWT tokens only contain standard OIDC claims (sub, email, etc.)
**Affected Areas:**
- Cloud Proxy Service (`apps/cloud`) - request forwarding
- Tenant API instances (`apps/api`) - tenant context validation
- MCP Gateway (`apps/mcp`) - authentication flow
- Overall tenant isolation security model
## What
Implement HMAC-signed headers that the Cloud Proxy Service adds when forwarding requests to tenant instances. This provides secure, tamper-proof tenant information without relying on JWT custom claims.
**Components:**
- Header signing utility in Cloud Proxy Service
- Header validation middleware in Tenant API instances
- Shared secret configuration across services
- Fallback mechanisms for development and error cases
## How (High Level)
### 1. Header Format
Add these signed headers to all proxied requests:
```
X-BM-Tenant-ID: {tenant_id}
X-BM-Timestamp: {unix_timestamp}
X-BM-Signature: {hmac_sha256_signature}
```
### 2. Signature Algorithm
```python
# Canonical message format
message = f"{tenant_id}:{timestamp}"
# HMAC-SHA256 signature
signature = hmac.new(
key=shared_secret.encode('utf-8'),
msg=message.encode('utf-8'),
digestmod=hashlib.sha256
).hexdigest()
```
### 3. Implementation Flow
#### Cloud Proxy Service (`apps/cloud`)
1. Extract `tenant_id` from authenticated user profile
2. Generate timestamp and canonical message
3. Sign message with shared secret
4. Add headers to request before forwarding to tenant instance
#### Tenant API Instances (`apps/api`)
1. Middleware validates headers on all incoming requests
2. Extract tenant_id, timestamp from headers
3. Verify timestamp is within acceptable window (5 minutes)
4. Recompute signature and compare in constant time
5. If valid, make tenant context available to Basic Memory tools
### 4. Security Properties
- **Authenticity**: Only services with shared secret can create valid signatures
- **Integrity**: Header tampering invalidates signature
- **Replay Protection**: Timestamp prevents reuse of old signatures
- **Non-repudiation**: Each request is cryptographically tied to specific tenant
### 5. Configuration
```bash
# Shared across Cloud Proxy and Tenant instances
BM_TENANT_HEADER_SECRET=randomly-generated-256-bit-secret
# Tenant API configuration
BM_TENANT_HEADER_VALIDATION=true # true (production) | false (dev only)
```
## How to Evaluate
### Unit Tests
- [ ] Header signing utility generates correct signatures
- [ ] Header validation correctly accepts/rejects signatures
- [ ] Timestamp validation within acceptable windows
- [ ] Constant-time signature comparison prevents timing attacks
### Integration Tests
- [ ] End-to-end request flow from MCP client → proxy → tenant
- [ ] Tenant isolation verified with signed headers
- [ ] Error handling for missing/invalid headers
- [ ] Disabled validation in development environment
### Security Validation
- [ ] Shared secret rotation procedure
- [ ] Header tampering detection
- [ ] Clock skew tolerance testing
- [ ] Performance impact measurement
### Production Readiness
- [ ] Logging and monitoring of header validation
- [ ] Graceful degradation for header validation failures
- [ ] Documentation for secret management
- [ ] Deployment configuration templates
## Implementation Notes
### Shared Secret Management
- Generate cryptographically secure 256-bit secret
- Same secret deployed to Cloud Proxy and all Tenant instances
- Consider secret rotation strategy for production
### Error Handling
```python
# Strict mode (production)
if not validate_headers(request):
raise HTTPException(status_code=401, detail="Invalid tenant headers")
# Fallback mode (development)
if not validate_headers(request):
logger.warning("Invalid headers, falling back to default tenant")
tenant_id = "default"
```
### Performance Considerations
- HMAC-SHA256 computation is fast (~microseconds)
- Headers add ~200 bytes to each request
- Validation happens once per request in middleware
## Benefits
**Works with MCP dynamic client registration** - No dependency on JWT custom claims
**Simple and reliable** - Standard HMAC signature approach
**Secure by design** - Cryptographic authenticity and integrity
**Infrastructure controlled** - No external service dependencies
**Easy to implement** - Clear signature algorithm and validation
## Trade-offs
⚠️ **Shared secret management** - Need secure distribution and rotation
⚠️ **Clock synchronization** - Timestamp validation requires reasonably synced clocks
⚠️ **Header visibility** - Headers visible in logs (tenant_id not sensitive)
⚠️ **Additional complexity** - More moving parts in proxy forwarding
## Implementation Tasks
### Cloud Service (Header Signing)
- [ ] Create `utils/header_signing.py` with HMAC-SHA256 signing function
- [ ] Add `bm_tenant_header_secret` to Cloud service configuration
- [ ] Update `ProxyService.forward_request()` to call signing utility
- [ ] Add signed headers (X-BM-Tenant-ID, X-BM-Timestamp, X-BM-Signature)
### Tenant API (Header Validation)
- [ ] Create `utils/header_validation.py` with signature verification
- [ ] Add `bm_tenant_header_secret` to API service configuration
- [ ] Create `TenantHeaderValidationMiddleware` class
- [ ] Add middleware to FastAPI app (before other middleware)
- [ ] Skip validation for `/health` endpoint
- [ ] Store validated tenant_id in request.state
### Testing
- [ ] Unit test for header signing utility
- [ ] Unit test for header validation utility
- [ ] Integration test for proxy → tenant flow
- [ ] Test invalid/missing header handling
- [ ] Test timestamp window validation
- [ ] Test signature tampering detection
### Configuration & Deployment
- [ ] Update `.env.example` with BM_TENANT_HEADER_SECRET
- [ ] Generate secure 256-bit secret for production
- [ ] Update Fly.io secrets for both services
- [ ] Document secret rotation procedure
## Status
- [x] **Specification Complete** - Design finalized and documented
- [ ] **Implementation Started** - Header signing utility development
- [ ] **Cloud Proxy Updated** - ProxyService adds signed headers
- [ ] **Tenant Validation Added** - Middleware validates headers
- [ ] **Testing Complete** - All validation criteria met
- [ ] **Production Deployed** - Live with tenant isolation via headers
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.15.2"
__version__ = "0.16.0"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -0,0 +1,49 @@
"""Add mtime and size columns to Entity for sync optimization
Revision ID: 9d9c1cb7d8f5
Revises: a1b2c3d4e5f6
Create Date: 2025-10-20 05:07:55.173849
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "9d9c1cb7d8f5"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("entity", schema=None) as batch_op:
batch_op.add_column(sa.Column("mtime", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("size", sa.Integer(), nullable=True))
batch_op.drop_constraint(batch_op.f("fk_entity_project_id"), type_="foreignkey")
batch_op.create_foreign_key(
batch_op.f("fk_entity_project_id"), "project", ["project_id"], ["id"]
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("entity", schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f("fk_entity_project_id"), type_="foreignkey")
batch_op.create_foreign_key(
batch_op.f("fk_entity_project_id"),
"project",
["project_id"],
["id"],
ondelete="CASCADE",
)
batch_op.drop_column("size")
batch_op.drop_column("mtime")
# ### end Alembic commands ###
@@ -0,0 +1,37 @@
"""Add scan watermark tracking to Project
Revision ID: e7e1f4367280
Revises: 9d9c1cb7d8f5
Create Date: 2025-10-20 16:42:46.625075
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e7e1f4367280"
down_revision: Union[str, None] = "9d9c1cb7d8f5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("project", schema=None) as batch_op:
batch_op.add_column(sa.Column("last_scan_timestamp", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("last_file_count", sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("project", schema=None) as batch_op:
batch_op.drop_column("last_file_count")
batch_op.drop_column("last_scan_timestamp")
# ### end Alembic commands ###
+80 -12
View File
@@ -1,7 +1,7 @@
"""Router for project management."""
import os
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response, Query
from typing import Optional
from loguru import logger
@@ -18,6 +18,7 @@ from basic_memory.schemas.project_info import (
ProjectInfoRequest,
ProjectStatusResponse,
)
from basic_memory.utils import normalize_project_path
# Router for resources in a specific project
# The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
@@ -50,7 +51,7 @@ async def get_project(
return ProjectItem(
name=found_project.name,
path=found_project.path,
path=normalize_project_path(found_project.path),
is_default=found_project.is_default or False,
)
@@ -109,6 +110,10 @@ async def sync_project(
background_tasks: BackgroundTasks,
sync_service: SyncServiceDep,
project_config: ProjectConfigDep,
force_full: bool = Query(
False, description="Force full scan, bypassing watermark optimization"
),
run_in_background: bool = Query(True, description="Run in background"),
):
"""Force project filesystem sync to database.
@@ -118,17 +123,32 @@ async def sync_project(
background_tasks: FastAPI background tasks
sync_service: Sync service for this project
project_config: Project configuration
force_full: If True, force a full scan even if watermark exists
run_in_background: If True, run sync in background and return immediately
Returns:
Response confirming sync was initiated
Response confirming sync was initiated (background) or SyncReportResponse (foreground)
"""
background_tasks.add_task(sync_service.sync, project_config.home, project_config.name)
logger.info(f"Filesystem sync initiated for project: {project_config.name}")
if run_in_background:
background_tasks.add_task(
sync_service.sync, project_config.home, project_config.name, force_full=force_full
)
logger.info(
f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})"
)
return {
"status": "sync_started",
"message": f"Filesystem sync initiated for project '{project_config.name}'",
}
return {
"status": "sync_started",
"message": f"Filesystem sync initiated for project '{project_config.name}'",
}
else:
report = await sync_service.sync(
project_config.home, project_config.name, force_full=force_full
)
logger.info(
f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})"
)
return SyncReportResponse.from_sync_report(report)
@project_router.post("/status", response_model=SyncReportResponse)
@@ -167,7 +187,7 @@ async def list_projects(
project_items = [
ProjectItem(
name=project.name,
path=project.path,
path=normalize_project_path(project.path),
is_default=project.is_default or False,
)
for project in projects
@@ -180,8 +200,9 @@ async def list_projects(
# Add a new project
@project_resource_router.post("/projects", response_model=ProjectStatusResponse)
@project_resource_router.post("/projects", response_model=ProjectStatusResponse, status_code=201)
async def add_project(
response: Response,
project_data: ProjectInfoRequest,
project_service: ProjectServiceDep,
) -> ProjectStatusResponse:
@@ -193,6 +214,36 @@ async def add_project(
Returns:
Response confirming the project was added
"""
# Check if project already exists before attempting to add
existing_project = await project_service.get_project(project_data.name)
if existing_project:
# Project exists - check if paths match for true idempotency
# Normalize paths for comparison (resolve symlinks, etc.)
from pathlib import Path
requested_path = Path(project_data.path).resolve()
existing_path = Path(existing_project.path).resolve()
if requested_path == existing_path:
# Same name, same path - return 200 OK (idempotent)
response.status_code = 200
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
message=f"Project '{project_data.name}' already exists",
status="success",
default=existing_project.is_default or False,
new_project=ProjectItem(
name=existing_project.name,
path=existing_project.path,
is_default=existing_project.is_default or False,
),
)
else:
# Same name, different path - this is an error
raise HTTPException(
status_code=400,
detail=f"Project '{project_data.name}' already exists with different path. Existing: {existing_project.path}, Requested: {project_data.path}",
)
try: # pragma: no cover
# The service layer now handles cloud mode validation and path sanitization
await project_service.add_project(
@@ -216,11 +267,15 @@ async def add_project(
async def remove_project(
project_service: ProjectServiceDep,
name: str = Path(..., description="Name of the project to remove"),
delete_notes: bool = Query(
False, description="If True, delete project directory from filesystem"
),
) -> ProjectStatusResponse:
"""Remove a project from configuration and database.
Args:
name: The name of the project to remove
delete_notes: If True, delete the project directory from the filesystem
Returns:
Response confirming the project was removed
@@ -232,7 +287,20 @@ async def remove_project(
status_code=404, detail=f"Project: '{name}' does not exist"
) # pragma: no cover
await project_service.remove_project(name)
# Check if trying to delete the default project
if name == project_service.default_project:
available_projects = await project_service.list_projects()
other_projects = [p.name for p in available_projects if p.name != name]
detail = f"Cannot delete default project '{name}'. "
if other_projects:
detail += (
f"Set another project as default first. Available: {', '.join(other_projects)}"
)
else:
detail += "This is the only project in your configuration."
raise HTTPException(status_code=400, detail=detail)
await project_service.remove_project(name, delete_notes=delete_notes)
return ProjectStatusResponse(
message=f"Project '{name}' removed successfully",
@@ -151,6 +151,20 @@ async def write_resource(
try:
# Get content from request body
# Defensive type checking: ensure content is a string
# FastAPI should validate this, but if a dict somehow gets through
# (e.g., via JSON body parsing), we need to catch it here
if isinstance(content, dict):
logger.error(
f"Error writing resource {file_path}: "
f"content is a dict, expected string. Keys: {list(content.keys())}"
)
raise HTTPException(
status_code=400,
detail="content must be a string, not a dict. "
"Ensure request body is sent as raw string content, not JSON object.",
)
# Ensure it's UTF-8 string content
if isinstance(content, bytes): # pragma: no cover
content_str = content.decode("utf-8")
+5 -5
View File
@@ -77,7 +77,7 @@ class CLIAuth:
verification_uri = device_response["verification_uri"]
verification_uri_complete = device_response.get("verification_uri_complete")
console.print("\n[bold blue]🔐 Authentication Required[/bold blue]")
console.print("\n[bold blue]Authentication Required[/bold blue]")
console.print("\nTo authenticate, please visit:")
console.print(f"[bold cyan]{verification_uri}[/bold cyan]")
console.print(f"\nAnd enter this code: [bold yellow]{user_code}[/bold yellow]")
@@ -171,7 +171,7 @@ class CLIAuth:
# Secure the token file
os.chmod(self.token_file, 0o600)
console.print(f"[green]Tokens saved to {self.token_file}[/green]")
console.print(f"[green]Tokens saved to {self.token_file}[/green]")
def load_tokens(self) -> dict | None:
"""Load tokens from .bm-auth.json file."""
@@ -233,7 +233,7 @@ class CLIAuth:
if new_tokens:
# Save new tokens (may include rotated refresh token)
self.save_tokens(new_tokens)
console.print("[green]Token refreshed successfully[/green]")
console.print("[green]Token refreshed successfully[/green]")
return new_tokens["access_token"]
else:
console.print("[yellow]Token refresh failed. Please run 'login' again.[/yellow]")
@@ -265,13 +265,13 @@ class CLIAuth:
# Step 4: Save tokens
self.save_tokens(tokens)
console.print("\n[green]Successfully authenticated with Basic Memory Cloud![/green]")
console.print("\n[green]Successfully authenticated with Basic Memory Cloud![/green]")
return True
def logout(self) -> None:
"""Remove stored authentication tokens."""
if self.token_file.exists():
self.token_file.unlink()
console.print("[green]Logged out successfully[/green]")
console.print("[green]Logged out successfully[/green]")
else:
console.print("[yellow]No stored authentication found[/yellow]")
+1 -2
View File
@@ -1,11 +1,10 @@
"""CLI commands for basic-memory."""
from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
from . import status, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project
__all__ = [
"status",
"sync",
"db",
"import_memory_json",
"mcp",
@@ -1,33 +1,11 @@
"""Cloud bisync commands for Basic Memory CLI."""
"""Cloud bisync utility functions for Basic Memory CLI."""
import asyncio
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
from basic_memory.cli.commands.cloud.cloud_utils import (
create_cloud_project,
fetch_cloud_projects,
)
from basic_memory.cli.commands.cloud.rclone_config import (
add_tenant_to_rclone_config,
)
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
from basic_memory.cli.commands.cloud.api_client import make_api_request
from basic_memory.config import ConfigManager
from basic_memory.ignore_utils import get_bmignore_path, create_default_bmignore
from basic_memory.schemas.cloud import (
TenantMountInfo,
MountCredentials,
)
console = Console()
from basic_memory.ignore_utils import create_default_bmignore, get_bmignore_path
from basic_memory.schemas.cloud import MountCredentials, TenantMountInfo
class BisyncError(Exception):
@@ -36,52 +14,6 @@ class BisyncError(Exception):
pass
class RcloneBisyncProfile:
"""Bisync profile with safety settings."""
def __init__(
self,
name: str,
conflict_resolve: str,
max_delete: int,
check_access: bool,
description: str,
extra_args: Optional[list[str]] = None,
):
self.name = name
self.conflict_resolve = conflict_resolve
self.max_delete = max_delete
self.check_access = check_access
self.description = description
self.extra_args = extra_args or []
# Bisync profiles based on SPEC-9 Phase 2.1
BISYNC_PROFILES = {
"safe": RcloneBisyncProfile(
name="safe",
conflict_resolve="none",
max_delete=10,
check_access=False,
description="Safe mode with conflict preservation (keeps both versions)",
),
"balanced": RcloneBisyncProfile(
name="balanced",
conflict_resolve="newer",
max_delete=25,
check_access=False,
description="Balanced mode - auto-resolve to newer file (recommended)",
),
"fast": RcloneBisyncProfile(
name="fast",
conflict_resolve="newer",
max_delete=50,
check_access=False,
description="Fast mode for rapid iteration (skip verification)",
),
}
async def get_mount_info() -> TenantMountInfo:
"""Get current tenant information from cloud API."""
try:
@@ -110,75 +42,6 @@ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
raise BisyncError(f"Failed to generate credentials: {e}") from e
def scan_local_directories(sync_dir: Path) -> list[str]:
"""Scan local sync directory for project folders.
Args:
sync_dir: Path to bisync directory
Returns:
List of directory names (project names)
"""
if not sync_dir.exists():
return []
directories = []
for item in sync_dir.iterdir():
if item.is_dir() and not item.name.startswith("."):
directories.append(item.name)
return directories
def get_bisync_state_path(tenant_id: str) -> Path:
"""Get path to bisync state directory."""
return Path.home() / ".basic-memory" / "bisync-state" / tenant_id
def get_bisync_directory() -> Path:
"""Get bisync directory from config.
Returns:
Path to bisync directory (default: ~/basic-memory-cloud-sync)
"""
config_manager = ConfigManager()
config = config_manager.config
sync_dir = config.bisync_config.get("sync_dir", str(Path.home() / "basic-memory-cloud-sync"))
return Path(sync_dir).expanduser().resolve()
def validate_bisync_directory(bisync_dir: Path) -> None:
"""Validate bisync directory doesn't conflict with mount.
Raises:
BisyncError: If bisync directory conflicts with mount directory
"""
# Get fixed mount directory
mount_dir = (Path.home() / "basic-memory-cloud").resolve()
# Check if bisync dir is the same as mount dir
if bisync_dir == mount_dir:
raise BisyncError(
f"Cannot use {bisync_dir} for bisync - it's the mount directory!\n"
f"Mount and bisync must use different directories.\n\n"
f"Options:\n"
f" 1. Use default: ~/basic-memory-cloud-sync/\n"
f" 2. Specify different directory: --dir ~/my-sync-folder"
)
# Check if mount is active at this location
result = subprocess.run(["mount"], capture_output=True, text=True)
if str(bisync_dir) in result.stdout and "rclone" in result.stdout:
raise BisyncError(
f"{bisync_dir} is currently mounted via 'bm cloud mount'\n"
f"Cannot use mounted directory for bisync.\n\n"
f"Either:\n"
f" 1. Unmount first: bm cloud unmount\n"
f" 2. Use different directory for bisync"
)
def convert_bmignore_to_rclone_filters() -> Path:
"""Convert .bmignore patterns to rclone filter format.
@@ -245,521 +108,3 @@ def get_bisync_filter_path() -> Path:
Path to rclone filter file
"""
return convert_bmignore_to_rclone_filters()
def bisync_state_exists(tenant_id: str) -> bool:
"""Check if bisync state exists (has been initialized)."""
state_path = get_bisync_state_path(tenant_id)
return state_path.exists() and any(state_path.iterdir())
def build_bisync_command(
tenant_id: str,
bucket_name: str,
local_path: Path,
profile: RcloneBisyncProfile,
dry_run: bool = False,
resync: bool = False,
verbose: bool = False,
) -> list[str]:
"""Build rclone bisync command with profile settings."""
# Sync with the entire bucket root (all projects)
rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
filter_path = get_bisync_filter_path()
state_path = get_bisync_state_path(tenant_id)
# Ensure state directory exists
state_path.mkdir(parents=True, exist_ok=True)
cmd = [
"rclone",
"bisync",
str(local_path),
rclone_remote,
"--create-empty-src-dirs",
"--resilient",
f"--conflict-resolve={profile.conflict_resolve}",
f"--max-delete={profile.max_delete}",
"--filters-file",
str(filter_path),
"--workdir",
str(state_path),
]
# Add verbosity flags
if verbose:
cmd.append("--verbose") # Full details with file-by-file output
else:
# Show progress bar during transfers
cmd.append("--progress")
if profile.check_access:
cmd.append("--check-access")
if dry_run:
cmd.append("--dry-run")
if resync:
cmd.append("--resync")
cmd.extend(profile.extra_args)
return cmd
def setup_cloud_bisync(sync_dir: Optional[str] = None) -> None:
"""Set up cloud bisync with rclone installation and configuration.
Args:
sync_dir: Optional custom sync directory path. If not provided, uses config default.
"""
console.print("[bold blue]Basic Memory Cloud Bisync Setup[/bold blue]")
console.print("Setting up bidirectional sync to your cloud tenant...\n")
try:
# Step 1: Install rclone
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# Step 2: Get mount info (for tenant_id, bucket)
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
bucket_name = tenant_info.bucket_name
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
# Step 3: Generate credentials
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_id))
access_key = creds.access_key
secret_key = creds.secret_key
console.print("[green]✓ Generated secure credentials[/green]")
# Step 4: Configure rclone
console.print("\n[blue]Step 4: Configuring rclone...[/blue]")
add_tenant_to_rclone_config(
tenant_id=tenant_id,
bucket_name=bucket_name,
access_key=access_key,
secret_key=secret_key,
)
# Step 5: Configure and create local directory
console.print("\n[blue]Step 5: Configuring sync directory...[/blue]")
# If custom sync_dir provided, save to config
if sync_dir:
config_manager = ConfigManager()
config = config_manager.load_config()
config.bisync_config["sync_dir"] = sync_dir
config_manager.save_config(config)
console.print("[green]✓ Saved custom sync directory to config[/green]")
# Get bisync directory (from config or default)
local_path = get_bisync_directory()
# Validate bisync directory
validate_bisync_directory(local_path)
# Create directory
local_path.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓ Created sync directory: {local_path}[/green]")
# Step 6: Perform initial resync
console.print("\n[blue]Step 6: Performing initial sync...[/blue]")
console.print("[yellow]This will establish the baseline for bidirectional sync.[/yellow]")
run_bisync(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile_name="balanced",
resync=True,
)
console.print("\n[bold green]✓ Bisync setup completed successfully![/bold green]")
console.print("\nYour local files will now sync bidirectionally with the cloud!")
console.print(f"\nLocal directory: {local_path}")
console.print("\nUseful commands:")
console.print(" bm sync # Run sync (recommended)")
console.print(" bm sync --watch # Start watch mode")
console.print(" bm cloud status # Check sync status")
console.print(" bm cloud check # Verify file integrity")
console.print(" bm cloud bisync --dry-run # Preview changes (advanced)")
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
def run_bisync(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
profile_name: str = "balanced",
dry_run: bool = False,
resync: bool = False,
verbose: bool = False,
) -> bool:
"""Run rclone bisync with specified profile."""
try:
# Get tenant info if not provided
if not tenant_id or not bucket_name:
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
bucket_name = tenant_info.bucket_name
# Set default local path if not provided
if not local_path:
local_path = get_bisync_directory()
# Validate bisync directory
validate_bisync_directory(local_path)
# Check if local path exists
if not local_path.exists():
raise BisyncError(
f"Local directory {local_path} does not exist. Run 'basic-memory cloud bisync-setup' first."
)
# Get bisync profile
if profile_name not in BISYNC_PROFILES:
raise BisyncError(
f"Unknown profile: {profile_name}. Available: {list(BISYNC_PROFILES.keys())}"
)
profile = BISYNC_PROFILES[profile_name]
# Auto-register projects before sync (unless dry-run or resync)
if not dry_run and not resync:
try:
console.print("[dim]Checking for new projects...[/dim]")
# Fetch cloud projects and extract directory names from paths
cloud_data = asyncio.run(fetch_cloud_projects())
cloud_projects = cloud_data.projects
# Extract directory names from cloud project paths
# Compare directory names, not project names
# Cloud path /app/data/basic-memory -> directory name "basic-memory"
cloud_dir_names = set()
for p in cloud_projects:
path = p.path
# Strip /app/data/ prefix if present (cloud mode)
if path.startswith("/app/data/"):
path = path[len("/app/data/") :]
# Get the last segment (directory name)
dir_name = Path(path).name
cloud_dir_names.add(dir_name)
# Scan local directories
local_dirs = scan_local_directories(local_path)
# Create missing cloud projects
new_projects = []
for dir_name in local_dirs:
if dir_name not in cloud_dir_names:
new_projects.append(dir_name)
if new_projects:
console.print(
f"[blue]Found {len(new_projects)} new local project(s), creating on cloud...[/blue]"
)
for project_name in new_projects:
try:
asyncio.run(create_cloud_project(project_name))
console.print(f"[green] ✓ Created project: {project_name}[/green]")
except BisyncError as e:
console.print(
f"[yellow] ⚠ Could not create {project_name}: {e}[/yellow]"
)
else:
console.print("[dim]All local projects already registered on cloud[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Project auto-registration failed: {e}[/yellow]")
console.print("[yellow]Continuing with sync anyway...[/yellow]")
# Check if first run and require resync
if not resync and not bisync_state_exists(tenant_id) and not dry_run:
raise BisyncError(
"First bisync requires --resync to establish baseline. "
"Run: basic-memory cloud bisync --resync"
)
# Build and execute bisync command
bisync_cmd = build_bisync_command(
tenant_id,
bucket_name,
local_path,
profile,
dry_run=dry_run,
resync=resync,
verbose=verbose,
)
if dry_run:
console.print("[yellow]DRY RUN MODE - No changes will be made[/yellow]")
console.print(
f"[blue]Running bisync with profile '{profile_name}' ({profile.description})...[/blue]"
)
console.print(f"[dim]Command: {' '.join(bisync_cmd)}[/dim]")
console.print() # Blank line before output
# Stream output in real-time so user sees progress
result = subprocess.run(bisync_cmd, text=True)
if result.returncode != 0:
raise BisyncError(f"Bisync command failed with code {result.returncode}")
console.print() # Blank line after output
if dry_run:
console.print("[green]✓ Dry run completed successfully[/green]")
elif resync:
console.print("[green]✓ Initial sync baseline established[/green]")
else:
console.print("[green]✓ Sync completed successfully[/green]")
# Notify container to refresh cache (if not dry run)
if not dry_run:
try:
asyncio.run(notify_container_sync(tenant_id))
except Exception as e:
console.print(f"[yellow]Warning: Could not notify container: {e}[/yellow]")
return True
except BisyncError:
raise
except Exception as e:
raise BisyncError(f"Unexpected error during bisync: {e}") from e
async def notify_container_sync(tenant_id: str) -> None:
"""Sync all projects after bisync completes."""
try:
from basic_memory.cli.commands.command_utils import run_sync
# Fetch all projects and sync each one
cloud_data = await fetch_cloud_projects()
projects = cloud_data.projects
if not projects:
console.print("[dim]No projects to sync[/dim]")
return
console.print(f"[blue]Notifying cloud to index {len(projects)} project(s)...[/blue]")
for project in projects:
project_name = project.name
if project_name:
try:
await run_sync(project=project_name)
except Exception as e:
# Non-critical, log and continue
console.print(f"[yellow] ⚠ Sync failed for {project_name}: {e}[/yellow]")
console.print("[dim]Note: Cloud indexing has started and may take a few moments[/dim]")
except Exception as e:
# Non-critical, don't fail the bisync
console.print(f"[yellow]Warning: Post-sync failed: {e}[/yellow]")
def run_bisync_watch(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
profile_name: str = "balanced",
interval_seconds: int = 60,
) -> None:
"""Run bisync in watch mode with periodic syncs."""
console.print("[bold blue]Starting bisync watch mode[/bold blue]")
console.print(f"Sync interval: {interval_seconds} seconds")
console.print("Press Ctrl+C to stop\n")
try:
while True:
try:
start_time = time.time()
run_bisync(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile_name=profile_name,
)
elapsed = time.time() - start_time
console.print(f"[dim]Sync completed in {elapsed:.1f}s[/dim]")
# Wait for next interval
time.sleep(interval_seconds)
except BisyncError as e:
console.print(f"[red]Sync error: {e}[/red]")
console.print(f"[yellow]Retrying in {interval_seconds} seconds...[/yellow]")
time.sleep(interval_seconds)
except KeyboardInterrupt:
console.print("\n[yellow]Watch mode stopped[/yellow]")
def show_bisync_status() -> None:
"""Show current bisync status and configuration."""
try:
# Get tenant info
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
local_path = get_bisync_directory()
state_path = get_bisync_state_path(tenant_id)
# Create status table
table = Table(title="Cloud Bisync Status", show_header=True, header_style="bold blue")
table.add_column("Property", style="green", min_width=20)
table.add_column("Value", style="dim", min_width=30)
# Check initialization status
is_initialized = bisync_state_exists(tenant_id)
init_status = (
"[green]✓ Initialized[/green]" if is_initialized else "[red]✗ Not initialized[/red]"
)
table.add_row("Tenant ID", tenant_id)
table.add_row("Local Directory", str(local_path))
table.add_row("Status", init_status)
table.add_row("State Directory", str(state_path))
# Check for last sync info
if is_initialized:
# Look for most recent state file
state_files = list(state_path.glob("*.lst"))
if state_files:
latest = max(state_files, key=lambda p: p.stat().st_mtime)
last_sync = datetime.fromtimestamp(latest.stat().st_mtime)
table.add_row("Last Sync", last_sync.strftime("%Y-%m-%d %H:%M:%S"))
console.print(table)
# Show bisync profiles
console.print("\n[bold]Available bisync profiles:[/bold]")
for name, profile in BISYNC_PROFILES.items():
console.print(f" {name}: {profile.description}")
console.print(f" - Conflict resolution: {profile.conflict_resolve}")
console.print(f" - Max delete: {profile.max_delete} files")
console.print("\n[dim]To use a profile: bm cloud bisync --profile <name>[/dim]")
# Show setup instructions if not initialized
if not is_initialized:
console.print("\n[yellow]To initialize bisync, run:[/yellow]")
console.print(" bm cloud setup")
console.print(" or")
console.print(" bm cloud bisync --resync")
except Exception as e:
console.print(f"[red]Error getting bisync status: {e}[/red]")
raise typer.Exit(1)
def run_check(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
one_way: bool = False,
) -> bool:
"""Check file integrity between local and cloud using rclone check.
Args:
tenant_id: Cloud tenant ID (auto-detected if not provided)
bucket_name: S3 bucket name (auto-detected if not provided)
local_path: Local bisync directory (uses config default if not provided)
one_way: If True, only check for missing files on destination (faster)
Returns:
True if check passed (files match), False if differences found
"""
try:
# Check if rclone is installed
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
if not is_rclone_installed():
raise BisyncError(
"rclone is not installed. Run 'bm cloud bisync-setup' first to set up cloud sync."
)
# Get tenant info if not provided
if not tenant_id or not bucket_name:
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_id or tenant_info.tenant_id
bucket_name = bucket_name or tenant_info.bucket_name
# Get local path from config
if not local_path:
local_path = get_bisync_directory()
# Check if bisync is initialized
if not bisync_state_exists(tenant_id):
raise BisyncError(
"Bisync not initialized. Run 'bm cloud bisync --resync' to establish baseline."
)
# Build rclone check command
rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
filter_path = get_bisync_filter_path()
cmd = [
"rclone",
"check",
str(local_path),
rclone_remote,
"--filter-from",
str(filter_path),
]
if one_way:
cmd.append("--one-way")
console.print("[bold blue]Checking file integrity between local and cloud[/bold blue]")
console.print(f"[dim]Local: {local_path}[/dim]")
console.print(f"[dim]Remote: {rclone_remote}[/dim]")
console.print(f"[dim]Command: {' '.join(cmd)}[/dim]")
console.print()
# Run check command
result = subprocess.run(cmd, capture_output=True, text=True)
# rclone check returns:
# 0 = success (all files match)
# non-zero = differences found or error
if result.returncode == 0:
console.print("[green]✓ All files match between local and cloud[/green]")
return True
else:
console.print("[yellow]⚠ Differences found:[/yellow]")
if result.stderr:
console.print(result.stderr)
if result.stdout:
console.print(result.stdout)
console.print("\n[dim]To sync differences, run: bm sync[/dim]")
return False
except BisyncError:
raise
except Exception as e:
raise BisyncError(f"Check failed: {e}") from e
@@ -69,16 +69,17 @@ async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
raise CloudUtilsError(f"Failed to create cloud project '{project_name}': {e}") from e
async def sync_project(project_name: str) -> None:
async def sync_project(project_name: str, force_full: bool = False) -> None:
"""Trigger sync for a specific project on cloud.
Args:
project_name: Name of project to sync
force_full: If True, force a full scan bypassing watermark optimization
"""
try:
from basic_memory.cli.commands.command_utils import run_sync
await run_sync(project=project_name)
await run_sync(project=project_name, force_full=force_full)
except Exception as e:
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
@@ -1,7 +1,6 @@
"""Core cloud commands for Basic Memory CLI."""
import asyncio
from typing import Optional
import typer
from rich.console import Console
@@ -15,21 +14,16 @@ from basic_memory.cli.commands.cloud.api_client import (
get_cloud_config,
make_api_request,
)
from basic_memory.cli.commands.cloud.mount_commands import (
mount_cloud_files,
setup_cloud_mount,
show_mount_status,
unmount_cloud_files,
)
from basic_memory.cli.commands.cloud.bisync_commands import (
run_bisync,
run_bisync_watch,
run_check,
setup_cloud_bisync,
show_bisync_status,
BisyncError,
generate_mount_credentials,
get_mount_info,
)
from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
from basic_memory.cli.commands.cloud.rclone_installer import (
RcloneInstallError,
install_rclone,
)
from basic_memory.cli.commands.cloud.rclone_config import MOUNT_PROFILES
from basic_memory.cli.commands.cloud.bisync_commands import BISYNC_PROFILES
console = Console()
@@ -58,11 +52,11 @@ def login():
config.cloud_mode = True
config_manager.save_config(config)
console.print("[green]Cloud mode enabled[/green]")
console.print("[green]Cloud mode enabled[/green]")
console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
console.print(
@@ -83,23 +77,13 @@ def logout():
config.cloud_mode = False
config_manager.save_config(config)
console.print("[green]Cloud mode disabled[/green]")
console.print("[green]Cloud mode disabled[/green]")
console.print("[dim]All CLI commands now work locally[/dim]")
@cloud_app.command("status")
def status(
bisync: bool = typer.Option(
True,
"--bisync/--mount",
help="Show bisync status (default) or mount status",
),
) -> None:
"""Check cloud mode status and cloud instance health.
Shows cloud mode status, instance health, and sync/mount status.
Use --bisync (default) to show bisync status or --mount for mount status.
"""
def status() -> None:
"""Check cloud mode status and cloud instance health."""
# Check cloud mode
config_manager = ConfigManager()
config = config_manager.load_config()
@@ -142,12 +126,7 @@ def status(
if "timestamp" in health_data:
console.print(f" Timestamp: {health_data['timestamp']}")
# Show sync/mount status based on flag
console.print()
if bisync:
show_bisync_status()
else:
show_mount_status()
console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
except CloudAPIError as e:
console.print(f"[red]Error checking cloud health: {e}[/red]")
@@ -157,132 +136,60 @@ def status(
raise typer.Exit(1)
# Mount commands
@cloud_app.command("setup")
def setup(
bisync: bool = typer.Option(
True,
"--bisync/--mount",
help="Use bidirectional sync (recommended) or mount as network drive",
),
sync_dir: Optional[str] = typer.Option(
None,
"--dir",
help="Custom sync directory for bisync (default: ~/basic-memory-cloud-sync)",
),
) -> None:
"""Set up cloud file access with automatic rclone installation and configuration.
def setup() -> None:
"""Set up cloud sync by installing rclone and configuring credentials.
Default: Sets up bidirectional sync (recommended).\n
Use --mount: Sets up mount as network drive (alternative workflow).\n
Examples:\n
bm cloud setup # Setup bisync (default)\n
bm cloud setup --mount # Setup mount instead\n
bm cloud setup --dir ~/sync # Custom bisync directory\n
SPEC-20: Simplified to project-scoped workflow.
After setup, use project commands for syncing:
bm project add <name> <path> --local-path ~/projects/<name>
bm project bisync --name <name> --resync # First time
bm project bisync --name <name> # Subsequent syncs
"""
if bisync:
setup_cloud_bisync(sync_dir=sync_dir)
else:
setup_cloud_mount()
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
console.print("Setting up cloud sync with rclone...\n")
@cloud_app.command("mount")
def mount(
profile: str = typer.Option(
"balanced", help=f"Mount profile: {', '.join(MOUNT_PROFILES.keys())}"
),
path: Optional[str] = typer.Option(
None, help="Custom mount path (default: ~/basic-memory-{tenant-id})"
),
) -> None:
"""Mount cloud files locally for editing."""
try:
mount_cloud_files(profile_name=profile)
except Exception as e:
console.print(f"[red]Mount failed: {e}[/red]")
# Step 1: Install rclone
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# Step 2: Get tenant info
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_mount_info())
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
# Step 3: Generate credentials
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
console.print("[green]Generated secure credentials[/green]")
# Step 4: Configure rclone remote
console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
configure_rclone_remote(
access_key=creds.access_key,
secret_key=creds.secret_key,
)
console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
console.print("\n[bold]Next steps:[/bold]")
console.print("1. Add a project with local sync path:")
console.print(" bm project add research --local-path ~/Documents/research")
console.print("\n Or configure sync for an existing project:")
console.print(" bm project sync-setup research ~/Documents/research")
console.print("\n2. Preview the initial sync (recommended):")
console.print(" bm project bisync --name research --resync --dry-run")
console.print("\n3. If all looks good, run the actual sync:")
console.print(" bm project bisync --name research --resync")
console.print("\n4. Subsequent syncs (no --resync needed):")
console.print(" bm project bisync --name research")
console.print(
"\n[dim]Tip: Always use --dry-run first to preview changes before syncing[/dim]"
)
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("unmount")
def unmount() -> None:
"""Unmount cloud files."""
try:
unmount_cloud_files()
except Exception as e:
console.print(f"[red]Unmount failed: {e}[/red]")
raise typer.Exit(1)
# Bisync commands
@cloud_app.command("bisync")
def bisync(
profile: str = typer.Option(
"balanced", help=f"Bisync profile: {', '.join(BISYNC_PROFILES.keys())}"
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
resync: bool = typer.Option(False, "--resync", help="Force resync to establish new baseline"),
watch: bool = typer.Option(False, "--watch", help="Run continuous sync in watch mode"),
interval: int = typer.Option(60, "--interval", help="Sync interval in seconds for watch mode"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed sync output"),
) -> None:
"""Run bidirectional sync between local files and cloud storage.
Examples:
basic-memory cloud bisync # Manual sync with balanced profile
basic-memory cloud bisync --dry-run # Preview what would be synced
basic-memory cloud bisync --resync # Establish new baseline
basic-memory cloud bisync --watch # Continuous sync every 60s
basic-memory cloud bisync --watch --interval 30 # Continuous sync every 30s
basic-memory cloud bisync --profile safe # Use safe profile (keep conflicts)
basic-memory cloud bisync --verbose # Show detailed file sync output
"""
try:
if watch:
run_bisync_watch(profile_name=profile, interval_seconds=interval)
else:
run_bisync(profile_name=profile, dry_run=dry_run, resync=resync, verbose=verbose)
except Exception as e:
console.print(f"[red]Bisync failed: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("bisync-status")
def bisync_status() -> None:
"""Show current bisync status and configuration.
DEPRECATED: Use 'bm cloud status' instead (bisync is now the default).
"""
console.print(
"[yellow]Note: 'bisync-status' is deprecated. Use 'bm cloud status' instead.[/yellow]"
)
console.print("[dim]Showing bisync status...[/dim]\n")
show_bisync_status()
@cloud_app.command("check")
def check(
one_way: bool = typer.Option(
False,
"--one-way",
help="Only check for missing files on destination (faster)",
),
) -> None:
"""Check file integrity between local and cloud storage using rclone check.
Verifies that files match between your local bisync directory and cloud storage
without transferring any data. This is useful for validating sync integrity.
Examples:
bm cloud check # Full integrity check
bm cloud check --one-way # Faster check (missing files only)
"""
try:
run_check(one_way=one_way)
except Exception as e:
console.print(f"[red]Check failed: {e}[/red]")
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
@@ -1,295 +0,0 @@
"""Cloud mount commands for Basic Memory CLI."""
import asyncio
import subprocess
import time
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
from basic_memory.cli.commands.cloud.rclone_config import (
MOUNT_PROFILES,
add_tenant_to_rclone_config,
build_mount_command,
cleanup_orphaned_rclone_processes,
get_default_mount_path,
get_rclone_processes,
is_path_mounted,
unmount_path,
)
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
from basic_memory.config import ConfigManager
console = Console()
class MountError(Exception):
"""Exception raised for mount-related errors."""
pass
async def get_tenant_info() -> dict:
"""Get current tenant information from cloud API."""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
return response.json()
except Exception as e:
raise MountError(f"Failed to get tenant info: {e}") from e
async def generate_mount_credentials(tenant_id: str) -> dict:
"""Generate scoped credentials for mounting."""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
return response.json()
except Exception as e:
raise MountError(f"Failed to generate mount credentials: {e}") from e
def setup_cloud_mount() -> None:
"""Set up cloud mount with rclone installation and configuration."""
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
console.print("Setting up local file access to your cloud tenant...\n")
try:
# Step 1: Install rclone
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# Step 2: Get tenant info
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_tenant_info())
tenant_id = tenant_info.get("tenant_id")
bucket_name = tenant_info.get("bucket_name")
if not tenant_id or not bucket_name:
raise MountError("Invalid tenant information received from cloud API")
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
# Step 3: Generate mount credentials
console.print("\n[blue]Step 3: Generating mount credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_id))
access_key = creds.get("access_key")
secret_key = creds.get("secret_key")
if not access_key or not secret_key:
raise MountError("Failed to generate mount credentials")
console.print("[green]✓ Generated secure credentials[/green]")
# Step 4: Configure rclone
console.print("\n[blue]Step 4: Configuring rclone...[/blue]")
add_tenant_to_rclone_config(
tenant_id=tenant_id,
bucket_name=bucket_name,
access_key=access_key,
secret_key=secret_key,
)
# Step 5: Perform initial mount
console.print("\n[blue]Step 5: Mounting cloud files...[/blue]")
mount_path = get_default_mount_path()
MOUNT_PROFILES["balanced"]
mount_cloud_files(
tenant_id=tenant_id,
bucket_name=bucket_name,
mount_path=mount_path,
profile_name="balanced",
)
console.print("\n[bold green]✓ Cloud setup completed successfully![/bold green]")
console.print("\nYour cloud files are now accessible at:")
console.print(f" {mount_path}")
console.print("\nYou can now edit files locally and they will sync to the cloud!")
console.print("\nUseful commands:")
console.print(" basic-memory cloud mount-status # Check mount status")
console.print(" basic-memory cloud unmount # Unmount files")
console.print(" basic-memory cloud mount --profile fast # Remount with faster sync")
except (RcloneInstallError, MountError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
def mount_cloud_files(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
mount_path: Optional[Path] = None,
profile_name: str = "balanced",
) -> None:
"""Mount cloud files with specified profile."""
try:
# Get tenant info if not provided
if not tenant_id or not bucket_name:
tenant_info = asyncio.run(get_tenant_info())
tenant_id = tenant_info.get("tenant_id")
bucket_name = tenant_info.get("bucket_name")
if not tenant_id or not bucket_name:
raise MountError("Could not determine tenant information")
# Set default mount path if not provided
if not mount_path:
mount_path = get_default_mount_path()
# Get mount profile
if profile_name not in MOUNT_PROFILES:
raise MountError(
f"Unknown profile: {profile_name}. Available: {list(MOUNT_PROFILES.keys())}"
)
profile = MOUNT_PROFILES[profile_name]
# Check if already mounted
if is_path_mounted(mount_path):
console.print(f"[yellow]Path {mount_path} is already mounted[/yellow]")
console.print("Use 'basic-memory cloud unmount' first, or mount to a different path")
return
# Create mount directory
mount_path.mkdir(parents=True, exist_ok=True)
# Build and execute mount command
mount_cmd = build_mount_command(tenant_id, bucket_name, mount_path, profile)
console.print(
f"[blue]Mounting with profile '{profile_name}' ({profile.description})...[/blue]"
)
console.print(f"[dim]Command: {' '.join(mount_cmd)}[/dim]")
result = subprocess.run(mount_cmd, capture_output=True, text=True)
if result.returncode != 0:
error_msg = result.stderr or "Unknown error"
raise MountError(f"Mount command failed: {error_msg}")
# Wait a moment for mount to establish
time.sleep(2)
# Verify mount
if is_path_mounted(mount_path):
console.print(f"[green]✓ Successfully mounted to {mount_path}[/green]")
console.print(f"[green]✓ Sync profile: {profile.description}[/green]")
else:
raise MountError("Mount command succeeded but path is not mounted")
except MountError:
raise
except Exception as e:
raise MountError(f"Unexpected error during mount: {e}") from e
def unmount_cloud_files(tenant_id: Optional[str] = None) -> None:
"""Unmount cloud files."""
try:
# Get tenant info if not provided
if not tenant_id:
tenant_info = asyncio.run(get_tenant_info())
tenant_id = tenant_info.get("tenant_id")
if not tenant_id:
raise MountError("Could not determine tenant ID")
mount_path = get_default_mount_path()
if not is_path_mounted(mount_path):
console.print(f"[yellow]Path {mount_path} is not mounted[/yellow]")
return
console.print(f"[blue]Unmounting {mount_path}...[/blue]")
# Unmount the path
if unmount_path(mount_path):
console.print(f"[green]✓ Successfully unmounted {mount_path}[/green]")
# Clean up any orphaned rclone processes
killed_count = cleanup_orphaned_rclone_processes()
if killed_count > 0:
console.print(
f"[green]✓ Cleaned up {killed_count} orphaned rclone process(es)[/green]"
)
else:
console.print(f"[red]✗ Failed to unmount {mount_path}[/red]")
console.print("You may need to manually unmount or restart your system")
except MountError:
raise
except Exception as e:
raise MountError(f"Unexpected error during unmount: {e}") from e
def show_mount_status() -> None:
"""Show current mount status and running processes."""
try:
# Get tenant info
tenant_info = asyncio.run(get_tenant_info())
tenant_id = tenant_info.get("tenant_id")
if not tenant_id:
console.print("[red]Could not determine tenant ID[/red]")
return
mount_path = get_default_mount_path()
# Create status table
table = Table(title="Cloud Mount Status", show_header=True, header_style="bold blue")
table.add_column("Property", style="green", min_width=15)
table.add_column("Value", style="dim", min_width=30)
# Check mount status
is_mounted = is_path_mounted(mount_path)
mount_status = "[green]✓ Mounted[/green]" if is_mounted else "[red]✗ Not mounted[/red]"
table.add_row("Tenant ID", tenant_id)
table.add_row("Mount Path", str(mount_path))
table.add_row("Status", mount_status)
# Get rclone processes
processes = get_rclone_processes()
if processes:
table.add_row("rclone Processes", f"{len(processes)} running")
else:
table.add_row("rclone Processes", "None")
console.print(table)
# Show running processes details
if processes:
console.print("\n[bold]Running rclone processes:[/bold]")
for proc in processes:
console.print(f" PID {proc['pid']}: {proc['command'][:80]}...")
# Show mount profiles
console.print("\n[bold]Available mount profiles:[/bold]")
for name, profile in MOUNT_PROFILES.items():
console.print(f" {name}: {profile.description}")
except Exception as e:
console.print(f"[red]Error getting mount status: {e}[/red]")
raise typer.Exit(1)
@@ -0,0 +1,299 @@
"""Project-scoped rclone sync commands for Basic Memory Cloud.
This module provides simplified, project-scoped rclone operations:
- Each project syncs independently
- Uses single "basic-memory-cloud" remote (not tenant-specific)
- Balanced defaults from SPEC-8 Phase 4 testing
- Per-project bisync state tracking
Replaces tenant-wide sync with project-scoped workflows.
"""
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from rich.console import Console
from basic_memory.utils import normalize_project_path
console = Console()
class RcloneError(Exception):
"""Exception raised for rclone command errors."""
pass
@dataclass
class SyncProject:
"""Project configured for cloud sync.
Attributes:
name: Project name
path: Cloud path (e.g., "app/data/research")
local_sync_path: Local directory for syncing (optional)
"""
name: str
path: str
local_sync_path: Optional[str] = None
def get_bmignore_filter_path() -> Path:
"""Get path to rclone filter file.
Uses ~/.basic-memory/.bmignore converted to rclone format.
File is automatically created with default patterns on first use.
Returns:
Path to rclone filter file
"""
# Import here to avoid circular dependency
from basic_memory.cli.commands.cloud.bisync_commands import (
convert_bmignore_to_rclone_filters,
)
return convert_bmignore_to_rclone_filters()
def get_project_bisync_state(project_name: str) -> Path:
"""Get path to project's bisync state directory.
Args:
project_name: Name of the project
Returns:
Path to bisync state directory for this project
"""
return Path.home() / ".basic-memory" / "bisync-state" / project_name
def bisync_initialized(project_name: str) -> bool:
"""Check if bisync has been initialized for this project.
Args:
project_name: Name of the project
Returns:
True if bisync state exists, False otherwise
"""
state_path = get_project_bisync_state(project_name)
return state_path.exists() and any(state_path.iterdir())
def get_project_remote(project: SyncProject, bucket_name: str) -> str:
"""Build rclone remote path for project.
Args:
project: Project with cloud path
bucket_name: S3 bucket name
Returns:
Remote path like "basic-memory-cloud:bucket-name/basic-memory-llc"
Note:
The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
is mounted at /app/data on the fly machine. We need to strip the /app/data/
prefix to get the actual S3 path within the bucket.
"""
# Normalize path to strip /app/data/ mount point prefix
cloud_path = normalize_project_path(project.path).lstrip("/")
return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
def project_sync(
project: SyncProject,
bucket_name: str,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""One-way sync: local → cloud.
Makes cloud identical to local using rclone sync.
Args:
project: Project to sync
bucket_name: S3 bucket name
dry_run: Preview changes without applying
verbose: Show detailed output
Returns:
True if sync succeeded, False otherwise
Raises:
RcloneError: If project has no local_sync_path configured
"""
if not project.local_sync_path:
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
local_path = Path(project.local_sync_path).expanduser()
remote_path = get_project_remote(project, bucket_name)
filter_path = get_bmignore_filter_path()
cmd = [
"rclone",
"sync",
str(local_path),
remote_path,
"--filter-from",
str(filter_path),
]
if verbose:
cmd.append("--verbose")
else:
cmd.append("--progress")
if dry_run:
cmd.append("--dry-run")
result = subprocess.run(cmd, text=True)
return result.returncode == 0
def project_bisync(
project: SyncProject,
bucket_name: str,
dry_run: bool = False,
resync: bool = False,
verbose: bool = False,
) -> bool:
"""Two-way sync: local ↔ cloud.
Uses rclone bisync with balanced defaults:
- conflict_resolve: newer (auto-resolve to most recent)
- max_delete: 25 (safety limit)
- check_access: false (skip for performance)
Args:
project: Project to sync
bucket_name: S3 bucket name
dry_run: Preview changes without applying
resync: Force resync to establish new baseline
verbose: Show detailed output
Returns:
True if bisync succeeded, False otherwise
Raises:
RcloneError: If project has no local_sync_path or needs --resync
"""
if not project.local_sync_path:
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
local_path = Path(project.local_sync_path).expanduser()
remote_path = get_project_remote(project, bucket_name)
filter_path = get_bmignore_filter_path()
state_path = get_project_bisync_state(project.name)
# Ensure state directory exists
state_path.mkdir(parents=True, exist_ok=True)
cmd = [
"rclone",
"bisync",
str(local_path),
remote_path,
"--create-empty-src-dirs",
"--resilient",
"--conflict-resolve=newer",
"--max-delete=25",
"--filter-from",
str(filter_path),
"--workdir",
str(state_path),
]
if verbose:
cmd.append("--verbose")
else:
cmd.append("--progress")
if dry_run:
cmd.append("--dry-run")
if resync:
cmd.append("--resync")
# Check if first run requires resync
if not resync and not bisync_initialized(project.name) and not dry_run:
raise RcloneError(
f"First bisync for {project.name} requires --resync to establish baseline.\n"
f"Run: bm project bisync --name {project.name} --resync"
)
result = subprocess.run(cmd, text=True)
return result.returncode == 0
def project_check(
project: SyncProject,
bucket_name: str,
one_way: bool = False,
) -> bool:
"""Check integrity between local and cloud.
Verifies files match without transferring data.
Args:
project: Project to check
bucket_name: S3 bucket name
one_way: Only check for missing files on destination (faster)
Returns:
True if files match, False if differences found
Raises:
RcloneError: If project has no local_sync_path configured
"""
if not project.local_sync_path:
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
local_path = Path(project.local_sync_path).expanduser()
remote_path = get_project_remote(project, bucket_name)
filter_path = get_bmignore_filter_path()
cmd = [
"rclone",
"check",
str(local_path),
remote_path,
"--filter-from",
str(filter_path),
]
if one_way:
cmd.append("--one-way")
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def project_ls(
project: SyncProject,
bucket_name: str,
path: Optional[str] = None,
) -> list[str]:
"""List files in remote project.
Args:
project: Project to list files from
bucket_name: S3 bucket name
path: Optional subdirectory within project
Returns:
List of file paths
Raises:
subprocess.CalledProcessError: If rclone command fails
"""
remote_path = get_project_remote(project, bucket_name)
if path:
remote_path = f"{remote_path}/{path}"
cmd = ["rclone", "ls", remote_path]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return result.stdout.splitlines()
@@ -1,11 +1,14 @@
"""rclone configuration management for Basic Memory Cloud."""
"""rclone configuration management for Basic Memory Cloud.
This module provides simplified rclone configuration for SPEC-20.
Uses a single "basic-memory-cloud" remote for all operations.
"""
import configparser
import os
import shutil
import subprocess
from pathlib import Path
from typing import Dict, List, Optional
from typing import Optional
from rich.console import Console
@@ -18,64 +21,6 @@ class RcloneConfigError(Exception):
pass
class RcloneMountProfile:
"""Mount profile with optimized settings."""
def __init__(
self,
name: str,
cache_time: str,
poll_interval: str,
attr_timeout: str,
write_back: str,
description: str,
extra_args: Optional[List[str]] = None,
):
self.name = name
self.cache_time = cache_time
self.poll_interval = poll_interval
self.attr_timeout = attr_timeout
self.write_back = write_back
self.description = description
self.extra_args = extra_args or []
# Mount profiles based on SPEC-7 Phase 4 testing
MOUNT_PROFILES = {
"fast": RcloneMountProfile(
name="fast",
cache_time="5s",
poll_interval="3s",
attr_timeout="3s",
write_back="1s",
description="Ultra-fast development (5s sync, higher bandwidth)",
),
"balanced": RcloneMountProfile(
name="balanced",
cache_time="10s",
poll_interval="5s",
attr_timeout="5s",
write_back="2s",
description="Fast development (10-15s sync, recommended)",
),
"safe": RcloneMountProfile(
name="safe",
cache_time="15s",
poll_interval="10s",
attr_timeout="10s",
write_back="5s",
description="Conflict-aware mount with backup",
extra_args=[
"--conflict-suffix",
".conflict-{DateTimeExt}",
"--backup-dir",
"~/.basic-memory/conflicts",
"--track-renames",
],
),
}
def get_rclone_config_path() -> Path:
"""Get the path to rclone configuration file."""
config_dir = Path.home() / ".config" / "rclone"
@@ -116,173 +61,50 @@ def save_rclone_config(config: configparser.ConfigParser) -> None:
console.print(f"[dim]Updated rclone config: {config_path}[/dim]")
def add_tenant_to_rclone_config(
tenant_id: str,
bucket_name: str,
def configure_rclone_remote(
access_key: str,
secret_key: str,
endpoint: str = "https://fly.storage.tigris.dev",
region: str = "auto",
) -> str:
"""Add tenant configuration to rclone config file."""
"""Configure single rclone remote named 'basic-memory-cloud'.
This is the simplified approach from SPEC-20 that uses one remote
for all Basic Memory cloud operations (not tenant-specific).
Args:
access_key: S3 access key ID
secret_key: S3 secret access key
endpoint: S3-compatible endpoint URL
region: S3 region (default: auto)
Returns:
The remote name: "basic-memory-cloud"
"""
# Backup existing config
backup_rclone_config()
# Load existing config
config = load_rclone_config()
# Create section name
section_name = f"basic-memory-{tenant_id}"
# Single remote name (not tenant-specific)
REMOTE_NAME = "basic-memory-cloud"
# Add/update the tenant section
if not config.has_section(section_name):
config.add_section(section_name)
config.set(section_name, "type", "s3")
config.set(section_name, "provider", "Other")
config.set(section_name, "access_key_id", access_key)
config.set(section_name, "secret_access_key", secret_key)
config.set(section_name, "endpoint", endpoint)
config.set(section_name, "region", region)
# Add/update the remote section
if not config.has_section(REMOTE_NAME):
config.add_section(REMOTE_NAME)
config.set(REMOTE_NAME, "type", "s3")
config.set(REMOTE_NAME, "provider", "Other")
config.set(REMOTE_NAME, "access_key_id", access_key)
config.set(REMOTE_NAME, "secret_access_key", secret_key)
config.set(REMOTE_NAME, "endpoint", endpoint)
config.set(REMOTE_NAME, "region", region)
# Prevent unnecessary encoding of filenames (only encode slashes and invalid UTF-8)
# This prevents files with spaces like "Hello World.md" from being quoted
config.set(REMOTE_NAME, "encoding", "Slash,InvalidUtf8")
# Save updated config
save_rclone_config(config)
console.print(f"[green]✓ Added tenant {tenant_id} to rclone config[/green]")
return section_name
def remove_tenant_from_rclone_config(tenant_id: str) -> bool:
"""Remove tenant configuration from rclone config."""
config = load_rclone_config()
section_name = f"basic-memory-{tenant_id}"
if config.has_section(section_name):
backup_rclone_config()
config.remove_section(section_name)
save_rclone_config(config)
console.print(f"[green]✓ Removed tenant {tenant_id} from rclone config[/green]")
return True
return False
def get_default_mount_path() -> Path:
"""Get default mount path (fixed location per SPEC-9).
Returns:
Fixed mount path: ~/basic-memory-cloud/
"""
return Path.home() / "basic-memory-cloud"
def build_mount_command(
tenant_id: str, bucket_name: str, mount_path: Path, profile: RcloneMountProfile
) -> List[str]:
"""Build rclone mount command with optimized settings."""
rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
cmd = [
"rclone",
"nfsmount",
rclone_remote,
str(mount_path),
"--vfs-cache-mode",
"writes",
"--dir-cache-time",
profile.cache_time,
"--vfs-cache-poll-interval",
profile.poll_interval,
"--attr-timeout",
profile.attr_timeout,
"--vfs-write-back",
profile.write_back,
"--daemon",
]
# Add profile-specific extra arguments
cmd.extend(profile.extra_args)
return cmd
def is_path_mounted(mount_path: Path) -> bool:
"""Check if a path is currently mounted."""
if not mount_path.exists():
return False
try:
# Check if mount point is actually mounted by looking for mount table entry
result = subprocess.run(["mount"], capture_output=True, text=True, check=False)
if result.returncode == 0:
# Look for our mount path in mount output
mount_str = str(mount_path.resolve())
return mount_str in result.stdout
return False
except Exception:
return False
def get_rclone_processes() -> List[Dict[str, str]]:
"""Get list of running rclone processes."""
try:
# Use ps to find rclone processes
result = subprocess.run(
["ps", "-eo", "pid,args"], capture_output=True, text=True, check=False
)
processes = []
if result.returncode == 0:
for line in result.stdout.split("\n"):
if "rclone" in line and "basic-memory" in line:
parts = line.strip().split(None, 1)
if len(parts) >= 2:
processes.append({"pid": parts[0], "command": parts[1]})
return processes
except Exception:
return []
def kill_rclone_process(pid: str) -> bool:
"""Kill a specific rclone process."""
try:
subprocess.run(["kill", pid], check=True)
console.print(f"[green]✓ Killed rclone process {pid}[/green]")
return True
except subprocess.CalledProcessError:
console.print(f"[red]✗ Failed to kill rclone process {pid}[/red]")
return False
def unmount_path(mount_path: Path) -> bool:
"""Unmount a mounted path."""
if not is_path_mounted(mount_path):
return True
try:
subprocess.run(["umount", str(mount_path)], check=True)
console.print(f"[green]✓ Unmounted {mount_path}[/green]")
return True
except subprocess.CalledProcessError as e:
console.print(f"[red]✗ Failed to unmount {mount_path}: {e}[/red]")
return False
def cleanup_orphaned_rclone_processes() -> int:
"""Clean up orphaned rclone processes for basic-memory."""
processes = get_rclone_processes()
killed_count = 0
for proc in processes:
console.print(
f"[yellow]Found rclone process: {proc['pid']} - {proc['command'][:80]}...[/yellow]"
)
if kill_rclone_process(proc["pid"]):
killed_count += 1
return killed_count
console.print(f"[green]Configured rclone remote: {REMOTE_NAME}[/green]")
return REMOTE_NAME
@@ -1,5 +1,6 @@
"""Cross-platform rclone installation utilities."""
import os
import platform
import shutil
import subprocess
@@ -58,7 +59,7 @@ def install_rclone_macos() -> None:
try:
console.print("[blue]Installing rclone via Homebrew...[/blue]")
run_command(["brew", "install", "rclone"])
console.print("[green]rclone installed via Homebrew[/green]")
console.print("[green]rclone installed via Homebrew[/green]")
return
except RcloneInstallError:
console.print(
@@ -69,7 +70,7 @@ def install_rclone_macos() -> None:
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: brew install rclone"
@@ -83,7 +84,7 @@ def install_rclone_linux() -> None:
try:
console.print("[blue]Installing rclone via snap...[/blue]")
run_command(["sudo", "snap", "install", "rclone"])
console.print("[green]rclone installed via snap[/green]")
console.print("[green]rclone installed via snap[/green]")
return
except RcloneInstallError:
console.print("[yellow]Snap installation failed, trying apt...[/yellow]")
@@ -94,7 +95,7 @@ def install_rclone_linux() -> None:
console.print("[blue]Installing rclone via apt...[/blue]")
run_command(["sudo", "apt", "update"])
run_command(["sudo", "apt", "install", "-y", "rclone"])
console.print("[green]rclone installed via apt[/green]")
console.print("[green]rclone installed via apt[/green]")
return
except RcloneInstallError:
console.print("[yellow]apt installation failed, trying official script...[/yellow]")
@@ -103,7 +104,7 @@ def install_rclone_linux() -> None:
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: sudo snap install rclone"
@@ -116,8 +117,16 @@ def install_rclone_windows() -> None:
if shutil.which("winget"):
try:
console.print("[blue]Installing rclone via winget...[/blue]")
run_command(["winget", "install", "Rclone.Rclone"])
console.print("[green]✓ rclone installed via winget[/green]")
run_command(
[
"winget",
"install",
"Rclone.Rclone",
"--accept-source-agreements",
"--accept-package-agreements",
]
)
console.print("[green]rclone installed via winget[/green]")
return
except RcloneInstallError:
console.print("[yellow]winget installation failed, trying chocolatey...[/yellow]")
@@ -127,7 +136,7 @@ def install_rclone_windows() -> None:
try:
console.print("[blue]Installing rclone via chocolatey...[/blue]")
run_command(["choco", "install", "rclone", "-y"])
console.print("[green]rclone installed via chocolatey[/green]")
console.print("[green]rclone installed via chocolatey[/green]")
return
except RcloneInstallError:
console.print("[yellow]chocolatey installation failed, trying scoop...[/yellow]")
@@ -137,7 +146,7 @@ def install_rclone_windows() -> None:
try:
console.print("[blue]Installing rclone via scoop...[/blue]")
run_command(["scoop", "install", "rclone"])
console.print("[green]rclone installed via scoop[/green]")
console.print("[green]rclone installed via scoop[/green]")
return
except RcloneInstallError:
console.print("[yellow]scoop installation failed[/yellow]")
@@ -165,6 +174,7 @@ def install_rclone(platform_override: Optional[str] = None) -> None:
install_rclone_linux()
elif platform_name == "windows":
install_rclone_windows()
refresh_windows_path()
else:
raise RcloneInstallError(f"Unsupported platform: {platform_name}")
@@ -172,7 +182,7 @@ def install_rclone(platform_override: Optional[str] = None) -> None:
if not is_rclone_installed():
raise RcloneInstallError("rclone installation completed but command not found in PATH")
console.print("[green]rclone installation completed successfully[/green]")
console.print("[green]rclone installation completed successfully[/green]")
except RcloneInstallError:
raise
@@ -180,6 +190,47 @@ def install_rclone(platform_override: Optional[str] = None) -> None:
raise RcloneInstallError(f"Unexpected error during installation: {e}") from e
def refresh_windows_path() -> None:
"""Refresh the Windows PATH environment variable for the current session."""
if platform.system().lower() != "windows":
return
# Importing here after performing platform detection. Also note that we have to ignore pylance/pyright
# warnings about winreg attributes so that "errors" don't appear on non-Windows platforms.
import winreg
user_key_path = r"Environment"
system_key_path = r"System\CurrentControlSet\Control\Session Manager\Environment"
new_path = ""
# Read user PATH
try:
reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, user_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
user_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
except Exception:
user_path = ""
# Read system PATH
try:
reg_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, system_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
system_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
except Exception:
system_path = ""
# Merge user and system PATHs (system first, then user)
if system_path and user_path:
new_path = system_path + ";" + user_path
elif system_path:
new_path = system_path
elif user_path:
new_path = user_path
if new_path:
os.environ["PATH"] = new_path
def get_rclone_version() -> Optional[str]:
"""Get the installed rclone version."""
if not is_rclone_installed():
+132 -27
View File
@@ -10,14 +10,26 @@ from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_pat
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_put
# Archive file extensions that should be skipped during upload
ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2"}
async def upload_path(local_path: Path, project_name: str) -> bool:
async def upload_path(
local_path: Path,
project_name: str,
verbose: bool = False,
use_gitignore: bool = True,
dry_run: bool = False,
) -> bool:
"""
Upload a file or directory to cloud project via WebDAV.
Args:
local_path: Path to local file or directory
project_name: Name of cloud project (destination)
verbose: Show detailed information about filtering and upload
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
dry_run: If True, show what would be uploaded without uploading
Returns:
True if upload succeeded, False otherwise
@@ -34,35 +46,76 @@ async def upload_path(local_path: Path, project_name: str) -> bool:
# Get files to upload
if local_path.is_file():
files_to_upload = [(local_path, local_path.name)]
if verbose:
print(f"Uploading single file: {local_path.name}")
else:
files_to_upload = _get_files_to_upload(local_path)
files_to_upload = _get_files_to_upload(local_path, verbose, use_gitignore)
if not files_to_upload:
print("No files found to upload")
if verbose:
print(
"\nTip: Use --verbose to see which files are being filtered, "
"or --no-gitignore to skip .gitignore patterns"
)
return True
print(f"Found {len(files_to_upload)} file(s) to upload")
# Upload files using httpx
total_bytes = 0
# Calculate total size
total_bytes = sum(file_path.stat().st_size for file_path, _ in files_to_upload)
skipped_count = 0
async with get_client() as client:
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
# Build remote path: /webdav/{project_name}/{relative_path}
remote_path = f"/webdav/{project_name}/{relative_path}"
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
# If dry run, just show what would be uploaded
if dry_run:
print("\nFiles that would be uploaded:")
for file_path, relative_path in files_to_upload:
# Skip archive files
if _is_archive_file(file_path):
print(f" [SKIP] {relative_path} (archive file)")
skipped_count += 1
continue
# Read file content asynchronously
async with aiofiles.open(file_path, "rb") as f:
content = await f.read()
size = file_path.stat().st_size
if size < 1024:
size_str = f"{size} bytes"
elif size < 1024 * 1024:
size_str = f"{size / 1024:.1f} KB"
else:
size_str = f"{size / (1024 * 1024):.1f} MB"
print(f" {relative_path} ({size_str})")
else:
# Upload files using httpx
async with get_client() as client:
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
# Skip archive files (zip, tar, gz, etc.)
if _is_archive_file(file_path):
print(
f"Skipping archive file: {relative_path} ({i}/{len(files_to_upload)})"
)
skipped_count += 1
continue
# Upload via HTTP PUT to WebDAV endpoint
response = await call_put(client, remote_path, content=content)
response.raise_for_status()
# Build remote path: /webdav/{project_name}/{relative_path}
remote_path = f"/webdav/{project_name}/{relative_path}"
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
total_bytes += file_path.stat().st_size
# Get file modification time
file_stat = file_path.stat()
mtime = int(file_stat.st_mtime)
# Format size based on magnitude
# Read file content asynchronously
async with aiofiles.open(file_path, "rb") as f:
content = await f.read()
# Upload via HTTP PUT to WebDAV endpoint with mtime header
# Using X-OC-Mtime (ownCloud/Nextcloud standard)
response = await call_put(
client, remote_path, content=content, headers={"X-OC-Mtime": str(mtime)}
)
response.raise_for_status()
# Format total size based on magnitude
if total_bytes < 1024:
size_str = f"{total_bytes} bytes"
elif total_bytes < 1024 * 1024:
@@ -70,7 +123,16 @@ async def upload_path(local_path: Path, project_name: str) -> bool:
else:
size_str = f"{total_bytes / (1024 * 1024):.1f} MB"
print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")
uploaded_count = len(files_to_upload) - skipped_count
if dry_run:
print(f"\nTotal: {uploaded_count} file(s) ({size_str})")
if skipped_count > 0:
print(f" Would skip {skipped_count} archive file(s)")
else:
print(f"✓ Upload complete: {uploaded_count} file(s) ({size_str})")
if skipped_count > 0:
print(f" Skipped {skipped_count} archive file(s)")
return True
except httpx.HTTPStatusError as e:
@@ -81,22 +143,51 @@ async def upload_path(local_path: Path, project_name: str) -> bool:
return False
def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
def _is_archive_file(file_path: Path) -> bool:
"""
Check if a file is an archive file based on its extension.
Args:
file_path: Path to the file to check
Returns:
True if file is an archive, False otherwise
"""
return file_path.suffix.lower() in ARCHIVE_EXTENSIONS
def _get_files_to_upload(
directory: Path, verbose: bool = False, use_gitignore: bool = True
) -> list[tuple[Path, str]]:
"""
Get list of files to upload from directory.
Uses .bmignore and .gitignore patterns for filtering.
Uses .bmignore and optionally .gitignore patterns for filtering.
Args:
directory: Directory to scan
verbose: Show detailed filtering information
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
Returns:
List of (absolute_path, relative_path) tuples
"""
files = []
ignored_files = []
# Load ignore patterns from .bmignore and .gitignore
ignore_patterns = load_gitignore_patterns(directory)
# Load ignore patterns from .bmignore and optionally .gitignore
ignore_patterns = load_gitignore_patterns(directory, use_gitignore=use_gitignore)
if verbose:
gitignore_path = directory / ".gitignore"
gitignore_exists = gitignore_path.exists() and use_gitignore
print(f"\nScanning directory: {directory}")
print("Using .bmignore: Yes")
print(f"Using .gitignore: {'Yes' if gitignore_exists else 'No'}")
print(f"Ignore patterns loaded: {len(ignore_patterns)}")
if ignore_patterns and len(ignore_patterns) <= 20:
print(f"Patterns: {', '.join(sorted(ignore_patterns))}")
print()
# Walk through directory
for root, dirs, filenames in os.walk(directory):
@@ -106,7 +197,11 @@ def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
filtered_dirs = []
for d in dirs:
dir_path = root_path / d
if not should_ignore_path(dir_path, directory, ignore_patterns):
if should_ignore_path(dir_path, directory, ignore_patterns):
if verbose:
rel_path = dir_path.relative_to(directory)
print(f" [IGNORED DIR] {rel_path}/")
else:
filtered_dirs.append(d)
dirs[:] = filtered_dirs
@@ -114,15 +209,25 @@ def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
for filename in filenames:
file_path = root_path / filename
# Calculate relative path for display/remote
rel_path = file_path.relative_to(directory)
remote_path = str(rel_path).replace("\\", "/")
# Check if file should be ignored
if should_ignore_path(file_path, directory, ignore_patterns):
ignored_files.append(remote_path)
if verbose:
print(f" [IGNORED] {remote_path}")
continue
# Calculate relative path for remote
rel_path = file_path.relative_to(directory)
# Use forward slashes for WebDAV paths
remote_path = str(rel_path).replace("\\", "/")
if verbose:
print(f" [INCLUDE] {remote_path}")
files.append((file_path, remote_path))
if verbose:
print("\nSummary:")
print(f" Files to upload: {len(files)}")
print(f" Files ignored: {len(ignored_files)}")
return files
@@ -43,6 +43,22 @@ def upload(
"--sync/--no-sync",
help="Sync project after upload (default: true)",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Show detailed information about file filtering and upload",
),
no_gitignore: bool = typer.Option(
False,
"--no-gitignore",
help="Skip .gitignore patterns (still respects .bmignore)",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Show what would be uploaded without actually uploading",
),
) -> None:
"""Upload local files or directories to cloud project via WebDAV.
@@ -50,6 +66,9 @@ def upload(
bm cloud upload ~/my-notes --project research
bm cloud upload notes.md --project research --create-project
bm cloud upload ~/docs --project work --no-sync
bm cloud upload ./history --project proto --verbose
bm cloud upload ./notes --project work --no-gitignore
bm cloud upload ./files --project test --dry-run
"""
async def _upload():
@@ -59,7 +78,7 @@ def upload(
console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
try:
await create_cloud_project(project)
console.print(f"[green]Created project '{project}'[/green]")
console.print(f"[green]Created project '{project}'[/green]")
except Exception as e:
console.print(f"[red]Failed to create project: {e}[/red]")
raise typer.Exit(1)
@@ -72,20 +91,32 @@ def upload(
)
raise typer.Exit(1)
# Perform upload
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
success = await upload_path(path, project)
# Perform upload (or dry run)
if dry_run:
console.print(
f"[yellow]DRY RUN: Showing what would be uploaded to '{project}'[/yellow]"
)
else:
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
success = await upload_path(
path, project, verbose=verbose, use_gitignore=not no_gitignore, dry_run=dry_run
)
if not success:
console.print("[red]Upload failed[/red]")
raise typer.Exit(1)
console.print(f"[green]✅ Successfully uploaded to '{project}'[/green]")
if dry_run:
console.print("[yellow]DRY RUN complete - no files were uploaded[/yellow]")
else:
console.print(f"[green]Successfully uploaded to '{project}'[/green]")
# Sync project if requested
if sync:
# Sync project if requested (skip on dry run)
# Force full scan after bisync to ensure database is up-to-date with synced files
if sync and not dry_run:
console.print(f"[blue]Syncing project '{project}'...[/blue]")
try:
await sync_project(project)
await sync_project(project, force_full=True)
except Exception as e:
console.print(f"[yellow]Warning: Sync failed: {e}[/yellow]")
console.print("[dim]Files uploaded but may not be indexed yet[/dim]")
+14 -6
View File
@@ -16,17 +16,25 @@ from basic_memory.schemas import ProjectInfoResponse
console = Console()
async def run_sync(project: Optional[str] = None):
"""Run sync operation via API endpoint."""
async def run_sync(project: Optional[str] = None, force_full: bool = False):
"""Run sync operation via API endpoint.
Args:
project: Optional project name
force_full: If True, force a full scan bypassing watermark optimization
"""
try:
async with get_client() as client:
project_item = await get_active_project(client, project, None)
response = await call_post(client, f"{project_item.project_url}/project/sync")
url = f"{project_item.project_url}/project/sync"
if force_full:
url += "?force_full=true"
response = await call_post(client, url)
data = response.json()
console.print(f"[green]{data['message']}[/green]")
console.print(f"[green]{data['message']}[/green]")
except (ToolError, ValueError) as e:
console.print(f"[red]Sync failed: {e}[/red]")
console.print(f"[red]Sync failed: {e}[/red]")
raise typer.Exit(1)
@@ -39,5 +47,5 @@ async def get_project_info(project: str):
response = await call_get(client, f"{project_item.project_url}/project/info")
return ProjectInfoResponse.model_validate(response.json())
except (ToolError, ValueError) as e:
console.print(f"[red]Sync failed: {e}[/red]")
console.print(f"[red]Sync failed: {e}[/red]")
raise typer.Exit(1)
+3 -3
View File
@@ -37,8 +37,8 @@ def reset(
logger.info("Database reset complete")
if reindex:
# Import and run sync
from basic_memory.cli.commands.sync import sync
# Run database sync directly
from basic_memory.cli.commands.command_utils import run_sync
logger.info("Rebuilding search index from filesystem...")
sync(watch=False) # pyright: ignore
asyncio.run(run_sync(project=None))
+544 -23
View File
@@ -22,9 +22,20 @@ from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.project_info import ProjectStatusResponse
from basic_memory.mcp.tools.utils import call_delete
from basic_memory.mcp.tools.utils import call_put
from basic_memory.utils import generate_permalink
from basic_memory.utils import generate_permalink, normalize_project_path
from basic_memory.mcp.tools.utils import call_patch
# Import rclone commands for project sync
from basic_memory.cli.commands.cloud.rclone_commands import (
SyncProject,
RcloneError,
project_sync,
project_bisync,
project_check,
project_ls,
)
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
console = Console()
# Create a project subcommand
@@ -51,15 +62,41 @@ def list_projects() -> None:
try:
result = asyncio.run(_list_projects())
config = ConfigManager().config
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
table.add_column("Path", style="green")
table.add_column("Default", style="magenta")
# Add Local Path column if in cloud mode
if config.cloud_mode_enabled:
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
# Show Default column in local mode or if default_project_mode is enabled in cloud mode
show_default_column = not config.cloud_mode_enabled or config.default_project_mode
if show_default_column:
table.add_column("Default", style="magenta")
for project in result.projects:
is_default = "" if project.is_default else ""
table.add_row(project.name, format_path(project.path), is_default)
is_default = "[X]" if project.is_default else ""
normalized_path = normalize_project_path(project.path)
# Build row based on mode
row = [project.name, format_path(normalized_path)]
# Add local path if in cloud mode
if config.cloud_mode_enabled:
local_path = ""
if project.name in config.cloud_projects:
local_path = config.cloud_projects[project.name].local_path or ""
local_path = format_path(local_path)
row.append(local_path)
# Add default indicator if showing default column
if show_default_column:
row.append(is_default)
table.add_row(*row)
console.print(table)
except Exception as e:
@@ -73,20 +110,38 @@ def add_project(
path: str = typer.Argument(
None, help="Path to the project directory (required for local mode)"
),
local_path: str = typer.Option(
None, "--local-path", help="Local sync path for cloud mode (optional)"
),
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
) -> None:
"""Add a new project.
For cloud mode: only name is required
For local mode: both name and path are required
Cloud mode examples:\n
bm project add research # No local sync\n
bm project add research --local-path ~/docs # With local sync\n
Local mode example:\n
bm project add research ~/Documents/research
"""
config = ConfigManager().config
# Resolve local sync path early (needed for both cloud and local mode)
local_sync_path: str | None = None
if local_path:
local_sync_path = Path(os.path.abspath(os.path.expanduser(local_path))).as_posix()
if config.cloud_mode_enabled:
# Cloud mode: path not needed (auto-generated from name)
# Cloud mode: path auto-generated from name, local sync is optional
async def _add_project():
async with get_client() as client:
data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
data = {
"name": name,
"path": generate_permalink(name),
"local_sync_path": local_sync_path,
"set_default": set_default,
}
response = await call_post(client, "/projects/projects", json=data)
return ProjectStatusResponse.model_validate(response.json())
else:
@@ -107,37 +162,156 @@ def add_project(
try:
result = asyncio.run(_add_project())
console.print(f"[green]{result.message}[/green]")
# Save local sync path to config if in cloud mode
if config.cloud_mode_enabled and local_sync_path:
from basic_memory.config import CloudProjectConfig
# Create local directory if it doesn't exist
local_dir = Path(local_sync_path)
local_dir.mkdir(parents=True, exist_ok=True)
# Update config with sync path
config.cloud_projects[name] = CloudProjectConfig(
local_path=local_sync_path,
last_sync=None,
bisync_initialized=False,
)
ConfigManager().save_config(config)
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
except Exception as e:
console.print(f"[red]Error adding project: {str(e)}[/red]")
raise typer.Exit(1)
# Display usage hint
console.print("\nTo use this project:")
console.print(f" basic-memory --project={name} <command>")
@project_app.command("sync-setup")
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.
Example:
bm project sync-setup research ~/Documents/research
"""
config_manager = ConfigManager()
config = config_manager.config
if not config.cloud_mode_enabled:
console.print("[red]Error: sync-setup only available in cloud mode[/red]")
raise typer.Exit(1)
async def _verify_project_exists():
"""Verify the project exists on cloud by listing all projects."""
async with get_client() as client:
response = await call_get(client, "/projects/projects")
project_list = response.json()
project_names = [p["name"] for p in project_list["projects"]]
if name not in project_names:
raise ValueError(f"Project '{name}' not found on cloud")
return True
try:
# Verify project exists on cloud
asyncio.run(_verify_project_exists())
# Resolve and create local path
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
resolved_path.mkdir(parents=True, exist_ok=True)
# Update local config with sync path
from basic_memory.config import CloudProjectConfig
config.cloud_projects[name] = CloudProjectConfig(
local_path=resolved_path.as_posix(),
last_sync=None,
bisync_initialized=False,
)
config_manager.save_config(config)
console.print(f"[green]Sync configured for project '{name}'[/green]")
console.print(f"\nLocal sync path: {resolved_path}")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
except Exception as e:
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
raise typer.Exit(1)
@project_app.command("remove")
def remove_project(
name: str = typer.Argument(..., help="Name of the project to remove"),
delete_notes: bool = typer.Option(
False, "--delete-notes", help="Delete project files from disk"
),
) -> None:
"""Remove a project."""
async def _remove_project():
async with get_client() as client:
project_permalink = generate_permalink(name)
response = await call_delete(client, f"/projects/{project_permalink}")
response = await call_delete(
client, f"/projects/{project_permalink}?delete_notes={delete_notes}"
)
return ProjectStatusResponse.model_validate(response.json())
try:
# Get config to check for local sync path and bisync state
config = ConfigManager().config
local_path = None
has_bisync_state = False
if config.cloud_mode_enabled and name in config.cloud_projects:
local_path = config.cloud_projects[name].local_path
# Check for bisync state
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
bisync_state_path = get_project_bisync_state(name)
has_bisync_state = bisync_state_path.exists()
# Remove project from cloud/API
result = asyncio.run(_remove_project())
console.print(f"[green]{result.message}[/green]")
# Clean up local sync directory if it exists and delete_notes is True
if delete_notes and local_path:
local_dir = Path(local_path)
if local_dir.exists():
import shutil
shutil.rmtree(local_dir)
console.print(f"[green]Removed local sync directory: {local_path}[/green]")
# Clean up bisync state if it exists
if has_bisync_state:
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
import shutil
bisync_state_path = get_project_bisync_state(name)
if bisync_state_path.exists():
shutil.rmtree(bisync_state_path)
console.print("[green]Removed bisync state[/green]")
# Clean up cloud_projects config entry
if config.cloud_mode_enabled and name in config.cloud_projects:
del config.cloud_projects[name]
ConfigManager().save_config(config)
# Show informative message if files were not deleted
if not delete_notes:
if local_path:
console.print(f"[yellow]Note: Local files remain at {local_path}[/yellow]")
except Exception as e:
console.print(f"[red]Error removing project: {str(e)}[/red]")
raise typer.Exit(1)
# Show this message regardless of method used
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
@project_app.command("default")
def set_default_project(
@@ -231,7 +405,7 @@ def move_project(
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
f"[cyan]{resolved_path}[/cyan]\n\n"
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
title="⚠️ Manual File Movement Required",
title="Manual File Movement Required",
border_style="yellow",
expand=False,
)
@@ -242,6 +416,353 @@ def move_project(
raise typer.Exit(1)
@project_app.command("sync")
def sync_project_command(
name: str = typer.Option(..., "--name", help="Project name to sync"),
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).
Example:
bm project sync --name research
bm project sync --name research --dry-run
"""
config = ConfigManager().config
if not config.cloud_mode_enabled:
console.print("[red]Error: sync only available in cloud mode[/red]")
raise typer.Exit(1)
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
async def _get_project():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
projects_list = ProjectList.model_validate(response.json())
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
project_data = asyncio.run(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
raise typer.Exit(1)
# Create SyncProject
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
)
# Run sync
console.print(f"[blue]Syncing {name} (local -> cloud)...[/blue]")
success = project_sync(sync_project, bucket_name, dry_run=dry_run, verbose=verbose)
if success:
console.print(f"[green]{name} synced successfully[/green]")
# Trigger database sync if not a dry run
if not dry_run:
async def _trigger_db_sync():
async with get_client() as client:
permalink = generate_permalink(name)
response = await call_post(
client, f"/{permalink}/project/sync?force_full=true", json={}
)
return response.json()
try:
result = asyncio.run(_trigger_db_sync())
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
else:
console.print(f"[red]{name} sync failed[/red]")
raise typer.Exit(1)
except RcloneError as e:
console.print(f"[red]Sync error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("bisync")
def bisync_project_command(
name: str = typer.Option(..., "--name", help="Project name to bisync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
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).
Examples:
bm project bisync --name research --resync # First time
bm project bisync --name research # Subsequent syncs
bm project bisync --name research --dry-run # Preview changes
"""
config = ConfigManager().config
if not config.cloud_mode_enabled:
console.print("[red]Error: bisync only available in cloud mode[/red]")
raise typer.Exit(1)
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
async def _get_project():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
projects_list = ProjectList.model_validate(response.json())
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
project_data = asyncio.run(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
raise typer.Exit(1)
# Create SyncProject
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
)
# Run bisync
console.print(f"[blue]Bisync {name} (local <-> cloud)...[/blue]")
success = project_bisync(
sync_project, bucket_name, dry_run=dry_run, resync=resync, verbose=verbose
)
if success:
console.print(f"[green]{name} bisync completed successfully[/green]")
# Update config
config.cloud_projects[name].last_sync = datetime.now()
config.cloud_projects[name].bisync_initialized = True
ConfigManager().save_config(config)
# Trigger database sync if not a dry run
if not dry_run:
async def _trigger_db_sync():
async with get_client() as client:
permalink = generate_permalink(name)
response = await call_post(
client, f"/{permalink}/project/sync?force_full=true", json={}
)
return response.json()
try:
result = asyncio.run(_trigger_db_sync())
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
else:
console.print(f"[red]{name} bisync failed[/red]")
raise typer.Exit(1)
except RcloneError as e:
console.print(f"[red]Bisync error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("check")
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.
Example:
bm project check --name research
"""
config = ConfigManager().config
if not config.cloud_mode_enabled:
console.print("[red]Error: check only available in cloud mode[/red]")
raise typer.Exit(1)
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
async def _get_project():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
projects_list = ProjectList.model_validate(response.json())
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
project_data = asyncio.run(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
raise typer.Exit(1)
# Create SyncProject
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
)
# Run check
console.print(f"[blue]Checking {name} integrity...[/blue]")
match = project_check(sync_project, bucket_name, one_way=one_way)
if match:
console.print(f"[green]{name} files match[/green]")
else:
console.print(f"[yellow]!{name} has differences[/yellow]")
except RcloneError as e:
console.print(f"[red]Check error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("bisync-reset")
def bisync_reset(
name: str = typer.Argument(..., help="Project name to reset bisync state for"),
) -> None:
"""Clear bisync state for a project.
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.
"""
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
import shutil
try:
state_path = get_project_bisync_state(name)
if not state_path.exists():
console.print(f"[yellow]No bisync state found for project '{name}'[/yellow]")
return
# Remove the entire state directory
shutil.rmtree(state_path)
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
except Exception as e:
console.print(f"[red]Error clearing bisync state: {str(e)}[/red]")
raise typer.Exit(1)
@project_app.command("ls")
def ls_project_command(
name: str = typer.Option(..., "--name", help="Project name to list files from"),
path: str = typer.Argument(None, help="Path within project (optional)"),
) -> None:
"""List files in remote project.
Examples:
bm project ls --name research
bm project ls --name research subfolder
"""
config = ConfigManager().config
if not config.cloud_mode_enabled:
console.print("[red]Error: ls only available in cloud mode[/red]")
raise typer.Exit(1)
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
async def _get_project():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
projects_list = ProjectList.model_validate(response.json())
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
project_data = asyncio.run(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Create SyncProject (local_sync_path not needed for ls)
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
)
# List files
files = project_ls(sync_project, bucket_name, path=path)
if files:
console.print(f"\n[bold]Files in {name}" + (f"/{path}" if path else "") + ":[/bold]")
for file in files:
console.print(f" {file}")
console.print(f"\n[dim]Total: {len(files)} files[/dim]")
else:
console.print(
f"[yellow]No files found in {name}" + (f"/{path}" if path else "") + "[/yellow]"
)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("info")
def display_project_info(
name: str = typer.Argument(..., help="Name of the project"),
@@ -263,13 +784,13 @@ def display_project_info(
f"[bold]Project:[/bold] {info.project_name}\n"
f"[bold]Path:[/bold] {info.project_path}\n"
f"[bold]Default Project:[/bold] {info.default_project}\n",
title="📊 Basic Memory Project Info",
title="Basic Memory Project Info",
expand=False,
)
)
# Statistics section
stats_table = Table(title="📈 Statistics")
stats_table = Table(title="Statistics")
stats_table.add_column("Metric", style="cyan")
stats_table.add_column("Count", style="green")
@@ -285,7 +806,7 @@ def display_project_info(
# Entity types
if info.statistics.entity_types:
entity_types_table = Table(title="📑 Entity Types")
entity_types_table = Table(title="Entity Types")
entity_types_table.add_column("Type", style="blue")
entity_types_table.add_column("Count", style="green")
@@ -296,7 +817,7 @@ def display_project_info(
# Most connected entities
if info.statistics.most_connected_entities: # pragma: no cover
connected_table = Table(title="🔗 Most Connected Entities")
connected_table = Table(title="Most Connected Entities")
connected_table.add_column("Title", style="blue")
connected_table.add_column("Permalink", style="cyan")
connected_table.add_column("Relations", style="green")
@@ -310,7 +831,7 @@ def display_project_info(
# Recent activity
if info.activity.recently_updated: # pragma: no cover
recent_table = Table(title="🕒 Recent Activity")
recent_table = Table(title="Recent Activity")
recent_table.add_column("Title", style="blue")
recent_table.add_column("Type", style="cyan")
recent_table.add_column("Last Updated", style="green")
@@ -330,7 +851,7 @@ def display_project_info(
console.print(recent_table)
# Available projects
projects_table = Table(title="📁 Available Projects")
projects_table = Table(title="Available Projects")
projects_table.add_column("Name", style="blue")
projects_table.add_column("Path", style="cyan")
projects_table.add_column("Default", style="green")
@@ -338,7 +859,7 @@ def display_project_info(
for name, proj_info in info.available_projects.items():
is_default = name == info.default_project
project_path = proj_info["path"]
projects_table.add_row(name, project_path, "" if is_default else "")
projects_table.add_row(name, project_path, "[X]" if is_default else "")
console.print(projects_table)
+17 -2
View File
@@ -94,7 +94,7 @@ def display_changes(
"""Display changes using Rich for better visualization."""
tree = Tree(f"{project_name}: {title}")
if changes.total == 0:
if changes.total == 0 and not changes.skipped_files:
tree.add("No changes")
console.print(Panel(tree, expand=False))
return
@@ -114,6 +114,13 @@ def display_changes(
if changes.deleted:
del_branch = tree.add("[red]Deleted[/red]")
add_files_to_tree(del_branch, changes.deleted, "red")
if changes.skipped_files:
skip_branch = tree.add("[red]! Skipped (Circuit Breaker)[/red]")
for skipped in sorted(changes.skipped_files, key=lambda x: x.path):
skip_branch.add(
f"[red]{skipped.path}[/red] "
f"(failures: {skipped.failure_count}, reason: {skipped.reason})"
)
else:
# Show directory summaries
by_dir = group_changes_by_directory(changes)
@@ -122,6 +129,14 @@ def display_changes(
if summary: # Only show directories with changes
tree.add(f"[bold]{dir_name}/[/bold] {summary}")
# Show skipped files summary in non-verbose mode
if changes.skipped_files:
skip_count = len(changes.skipped_files)
tree.add(
f"[red]! {skip_count} file{'s' if skip_count != 1 else ''} "
f"skipped due to repeated failures[/red]"
)
console.print(Panel(tree, expand=False))
@@ -137,7 +152,7 @@ async def run_status(project: Optional[str] = None, verbose: bool = False): # p
display_changes(project_item.name, "Status", sync_report, verbose)
except (ValueError, ToolError) as e:
console.print(f"[red]Error: {e}[/red]")
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
-59
View File
@@ -1,59 +0,0 @@
"""Command module for basic-memory sync operations."""
import asyncio
from typing import Annotated, Optional
import typer
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_sync
from basic_memory.config import ConfigManager
@app.command()
def sync(
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
watch: Annotated[
bool,
typer.Option("--watch", help="Run continuous sync (cloud mode only)"),
] = False,
interval: Annotated[
int,
typer.Option("--interval", help="Sync interval in seconds for watch mode (default: 60)"),
] = 60,
) -> None:
"""Sync knowledge files with the database.
In local mode: Scans filesystem and updates database.
In cloud mode: Runs bidirectional file sync (bisync) then updates database.
Examples:
bm sync # One-time sync
bm sync --watch # Continuous sync every 60s
bm sync --watch --interval 30 # Continuous sync every 30s
"""
config = ConfigManager().config
if config.cloud_mode_enabled:
# Cloud mode: run bisync which includes database sync
from basic_memory.cli.commands.cloud.bisync_commands import run_bisync, run_bisync_watch
try:
if watch:
run_bisync_watch(interval_seconds=interval)
else:
run_bisync()
except Exception:
raise typer.Exit(1)
else:
# Local mode: just database sync
if watch:
typer.echo(
"Error: --watch is only available in cloud mode. Run 'bm cloud login' first."
)
raise typer.Exit(1)
asyncio.run(run_sync(project))
-1
View File
@@ -13,7 +13,6 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
mcp,
project,
status,
sync,
tool,
)
+33 -11
View File
@@ -3,11 +3,12 @@
import json
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Literal, Optional, List, Tuple
from loguru import logger
from pydantic import Field, field_validator
from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import basic_memory
@@ -39,6 +40,22 @@ class ProjectConfig:
return f"/{generate_permalink(self.name)}"
class CloudProjectConfig(BaseModel):
"""Sync configuration for a cloud project.
This tracks the local working directory and sync state for a project
that is synced with Basic Memory Cloud.
"""
local_path: str = Field(description="Local working directory path for this cloud project")
last_sync: Optional[datetime] = Field(
default=None, description="Timestamp of last successful sync operation"
)
bisync_initialized: bool = Field(
default=False, description="Whether rclone bisync baseline has been established"
)
class BasicMemoryConfig(BaseSettings):
"""Pydantic model for Basic Memory global configuration."""
@@ -84,7 +101,13 @@ class BasicMemoryConfig(BaseSettings):
sync_thread_pool_size: int = Field(
default=4,
description="Size of thread pool for file I/O operations in sync service",
description="Size of thread pool for file I/O operations in sync service. Default of 4 is optimized for cloud deployments with 1-2GB RAM.",
gt=0,
)
sync_max_concurrent_files: int = Field(
default=10,
description="Maximum number of files to process concurrently during sync. Limits memory usage on large projects (2000+ files). Lower values reduce memory consumption.",
gt=0,
)
@@ -132,6 +155,11 @@ class BasicMemoryConfig(BaseSettings):
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
)
cloud_projects: Dict[str, CloudProjectConfig] = Field(
default_factory=dict,
description="Cloud project sync configuration mapping project names to their local paths and sync state",
)
@property
def cloud_mode_enabled(self) -> bool:
"""Check if cloud mode is enabled.
@@ -148,14 +176,6 @@ class BasicMemoryConfig(BaseSettings):
# Fall back to config file value
return self.cloud_mode
bisync_config: Dict[str, Any] = Field(
default_factory=lambda: {
"profile": "balanced",
"sync_dir": str(Path.home() / "basic-memory-cloud-sync"),
},
description="Bisync configuration for cloud sync",
)
model_config = SettingsConfigDict(
env_prefix="BASIC_MEMORY_",
extra="ignore",
@@ -421,7 +441,9 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
file_path.write_text(json.dumps(config.model_dump(), indent=2))
# Use model_dump with mode='json' to serialize datetime objects properly
config_dict = config.model_dump(mode="json")
file_path.write_text(json.dumps(config_dict, indent=2))
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
+2
View File
@@ -321,6 +321,7 @@ async def get_sync_service(
entity_parser: EntityParserDep,
entity_repository: EntityRepositoryDep,
relation_repository: RelationRepositoryDep,
project_repository: ProjectRepositoryDep,
search_service: SearchServiceDep,
file_service: FileServiceDep,
) -> SyncService: # pragma: no cover
@@ -334,6 +335,7 @@ async def get_sync_service(
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
project_repository=project_repository,
search_service=search_service,
file_service=file_service,
)
+20 -75
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import re
from typing import Any, Dict, Union
import aiofiles
import yaml
import frontmatter
from loguru import logger
@@ -52,29 +53,12 @@ async def compute_checksum(content: Union[str, bytes]) -> str:
raise FileError(f"Failed to compute checksum: {e}")
async def ensure_directory(path: FilePath) -> None:
"""
Ensure directory exists, creating if necessary.
Args:
path: Directory path to ensure (Path or string)
Raises:
FileWriteError: If directory creation fails
"""
try:
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
path_obj.mkdir(parents=True, exist_ok=True)
except Exception as e: # pragma: no cover
logger.error("Failed to create directory", path=str(path), error=str(e))
raise FileWriteError(f"Failed to create directory {path}: {e}")
async def write_file_atomic(path: FilePath, content: str) -> None:
"""
Write file with atomic operation using temporary file.
Uses aiofiles for true async I/O (non-blocking).
Args:
path: Target file path (Path or string)
content: Content to write
@@ -87,7 +71,11 @@ async def write_file_atomic(path: FilePath, content: str) -> None:
temp_path = path_obj.with_suffix(".tmp")
try:
temp_path.write_text(content, encoding="utf-8")
# Use aiofiles for non-blocking write
async with aiofiles.open(temp_path, mode="w", encoding="utf-8") as f:
await f.write(content)
# Atomic rename (this is fast, doesn't need async)
temp_path.replace(path_obj)
logger.debug("Wrote file atomically", path=str(path_obj), content_length=len(content))
except Exception as e: # pragma: no cover
@@ -185,74 +173,26 @@ def remove_frontmatter(content: str) -> str:
return parts[2].strip()
async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
"""Update frontmatter fields in a file while preserving all content.
Only modifies the frontmatter section, leaving all content untouched.
Creates frontmatter section if none exists.
Returns checksum of updated file.
Args:
path: Path to markdown file (Path or string)
updates: Dict of frontmatter fields to update
Returns:
Checksum of updated file
Raises:
FileError: If file operations fail
ParseError: If frontmatter parsing fails
"""
try:
# Convert string to Path if needed
path_obj = Path(path) if isinstance(path, str) else path
# Read current content
content = path_obj.read_text(encoding="utf-8")
# Parse current frontmatter
current_fm = {}
if has_frontmatter(content):
current_fm = parse_frontmatter(content)
content = remove_frontmatter(content)
# Update frontmatter
new_fm = {**current_fm, **updates}
# Write new file with updated frontmatter
yaml_fm = yaml.dump(new_fm, sort_keys=False, allow_unicode=True)
final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
logger.debug("Updating frontmatter", path=str(path_obj), update_keys=list(updates.keys()))
await write_file_atomic(path_obj, final_content)
return await compute_checksum(final_content)
except Exception as e: # pragma: no cover
logger.error(
"Failed to update frontmatter",
path=str(path) if isinstance(path, (str, Path)) else "<unknown>",
error=str(e),
)
raise FileError(f"Failed to update frontmatter: {e}")
def dump_frontmatter(post: frontmatter.Post) -> str:
"""
Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.
This function ensures that tags are formatted as YAML lists instead of JSON arrays:
This function ensures that:
1. Tags are formatted as YAML lists instead of JSON arrays
2. String values are properly quoted to handle special characters (colons, etc.)
Good (Obsidian compatible):
---
title: "L2 Governance Core (Split: Core)"
tags:
- system
- overview
- reference
---
Bad (current behavior):
Bad (causes parsing errors):
---
title: L2 Governance Core (Split: Core) # Unquoted colon breaks YAML
tags: ["system", "overview", "reference"]
---
@@ -267,8 +207,13 @@ def dump_frontmatter(post: frontmatter.Post) -> str:
return post.content
# Serialize YAML with block style for lists
# SafeDumper automatically quotes values with special characters (colons, etc.)
yaml_str = yaml.dump(
post.metadata, sort_keys=False, allow_unicode=True, default_flow_style=False
post.metadata,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
Dumper=yaml.SafeDumper,
)
# Construct the final markdown with frontmatter
+16 -14
View File
@@ -172,15 +172,16 @@ def load_bmignore_patterns() -> Set[str]:
return patterns
def load_gitignore_patterns(base_path: Path) -> Set[str]:
def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[str]:
"""Load gitignore patterns from .gitignore file and .bmignore.
Combines patterns from:
1. ~/.basic-memory/.bmignore (user's global ignore patterns)
2. {base_path}/.gitignore (project-specific patterns)
2. {base_path}/.gitignore (project-specific patterns, if use_gitignore=True)
Args:
base_path: The base directory to search for .gitignore file
use_gitignore: If False, only load patterns from .bmignore (default: True)
Returns:
Set of patterns to ignore
@@ -188,18 +189,19 @@ def load_gitignore_patterns(base_path: Path) -> Set[str]:
# Start with patterns from .bmignore
patterns = load_bmignore_patterns()
gitignore_file = base_path / ".gitignore"
if gitignore_file.exists():
try:
with gitignore_file.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if line and not line.startswith("#"):
patterns.add(line)
except Exception:
# If we can't read .gitignore, just use default patterns
pass
if use_gitignore:
gitignore_file = base_path / ".gitignore"
if gitignore_file.exists():
try:
with gitignore_file.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if line and not line.startswith("#"):
patterns.add(line)
except Exception:
# If we can't read .gitignore, just use default patterns
pass
return patterns
@@ -153,7 +153,12 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
# Handle message content
content = msg.get("text", "")
if msg.get("content"):
content = " ".join(c.get("text", "") for c in msg["content"])
# Filter out None values before joining
content = " ".join(
str(c.get("text", ""))
for c in msg["content"]
if c and c.get("text") is not None
)
lines.append(content)
# Handle attachments
+113 -8
View File
@@ -4,12 +4,14 @@ Uses markdown-it with plugins to parse structured data from markdown content.
"""
from dataclasses import dataclass, field
from datetime import datetime
from datetime import date, datetime
from pathlib import Path
from typing import Any, Optional
import dateparser
import frontmatter
import yaml
from loguru import logger
from markdown_it import MarkdownIt
from basic_memory.markdown.plugins import observation_plugin, relation_plugin
@@ -24,6 +26,82 @@ from basic_memory.utils import parse_tags
md = MarkdownIt().use(observation_plugin).use(relation_plugin)
def normalize_frontmatter_value(value: Any) -> Any:
"""Normalize frontmatter values to safe types for processing.
PyYAML automatically converts various string-like values into native Python types:
- Date strings ("2025-10-24") → datetime.date objects
- Numbers ("1.0") → int or float
- Booleans ("true") → bool
- Lists → list objects
This can cause AttributeError when code expects strings and calls string methods
like .strip() on these values (see GitHub issue #236).
This function normalizes all frontmatter values to safe types:
- Dates/datetimes → ISO format strings
- Numbers (int/float) → strings
- Booleans → strings ("True"/"False")
- Lists → preserved as lists, but items are recursively normalized
- Dicts → preserved as dicts, but values are recursively normalized
- Strings → kept as-is
- None → kept as None
Args:
value: The frontmatter value to normalize
Returns:
The normalized value safe for string operations
Example:
>>> normalize_frontmatter_value(datetime.date(2025, 10, 24))
'2025-10-24'
>>> normalize_frontmatter_value([datetime.date(2025, 10, 24), "tag", 123])
['2025-10-24', 'tag', '123']
>>> normalize_frontmatter_value(True)
'True'
"""
# Convert date/datetime objects to ISO format strings
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
# Convert boolean to string (must come before int check since bool is subclass of int)
if isinstance(value, bool):
return str(value)
# Convert numbers to strings
if isinstance(value, (int, float)):
return str(value)
# Recursively process lists (preserve as list, normalize items)
if isinstance(value, list):
return [normalize_frontmatter_value(item) for item in value]
# Recursively process dicts (preserve as dict, normalize values)
if isinstance(value, dict):
return {key: normalize_frontmatter_value(val) for key, val in value.items()}
# Keep strings and None as-is
return value
def normalize_frontmatter_metadata(metadata: dict) -> dict:
"""Normalize all values in frontmatter metadata dict.
Converts date/datetime objects to ISO format strings to prevent
AttributeError when code expects strings (GitHub issue #236).
Args:
metadata: The frontmatter metadata dictionary
Returns:
A new dictionary with all values normalized
"""
return {key: normalize_frontmatter_value(value) for key, value in metadata.items()}
@dataclass
class EntityContent:
content: str
@@ -111,18 +189,45 @@ class EntityParser:
return self.base_path / path
async def parse_file_content(self, absolute_path, file_content):
post = frontmatter.loads(file_content)
# Parse frontmatter with proper error handling for malformed YAML (issue #185)
try:
post = frontmatter.loads(file_content)
except yaml.YAMLError as e:
# Log the YAML parsing error with file context
logger.warning(
f"Failed to parse YAML frontmatter in {absolute_path}: {e}. "
f"Treating file as plain markdown without frontmatter."
)
# Create a post with no frontmatter - treat entire content as markdown
post = frontmatter.Post(file_content, metadata={})
# Extract file stat info
file_stats = absolute_path.stat()
metadata = post.metadata
metadata["title"] = post.metadata.get("title", absolute_path.stem)
metadata["type"] = post.metadata.get("type", "note")
tags = parse_tags(post.metadata.get("tags", [])) # pyright: ignore
# Normalize frontmatter values to prevent AttributeError on date objects (issue #236)
# PyYAML automatically converts date strings like "2025-10-24" to datetime.date objects
# This normalization converts them back to ISO format strings to ensure compatibility
# with code that expects string values
metadata = normalize_frontmatter_metadata(post.metadata)
# Ensure required fields have defaults (issue #184, #387)
# Handle title - use default if missing, None/null, empty, or string "None"
title = metadata.get("title")
if not title or title == "None":
metadata["title"] = absolute_path.stem
else:
metadata["title"] = title
# Handle type - use default if missing OR explicitly set to None/null
entity_type = metadata.get("type")
metadata["type"] = entity_type if entity_type is not None else "note"
tags = parse_tags(metadata.get("tags", [])) # pyright: ignore
if tags:
metadata["tags"] = tags
# frontmatter
# frontmatter - use metadata with defaults applied
entity_frontmatter = EntityFrontmatter(
metadata=post.metadata,
metadata=metadata,
)
entity_content = parse(post.content)
return EntityMarkdown(
-2
View File
@@ -18,7 +18,6 @@ from basic_memory.mcp.tools.canvas import canvas
from basic_memory.mcp.tools.list_directory import list_directory
from basic_memory.mcp.tools.edit_note import edit_note
from basic_memory.mcp.tools.move_note import move_note
from basic_memory.mcp.tools.sync_status import sync_status
from basic_memory.mcp.tools.project_management import (
list_memory_projects,
create_memory_project,
@@ -44,7 +43,6 @@ __all__ = [
"recent_activity",
"search",
"search_notes",
"sync_status",
"view_note",
"write_note",
]
+1 -25
View File
@@ -16,8 +16,6 @@ from basic_memory.schemas.memory import (
memory_url_path,
)
type StringOrInt = str | int
@mcp.tool(
description="""Build context from a memory:// URI to continue conversations naturally.
@@ -39,7 +37,7 @@ type StringOrInt = str | int
async def build_context(
url: MemoryUrl,
project: Optional[str] = None,
depth: Optional[StringOrInt] = 1,
depth: str | int | None = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
@@ -106,28 +104,6 @@ async def build_context(
# Get the active project using the new stateless approach
active_project = await get_active_project(client, project, context)
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
# Return a proper GraphContext with status message
from basic_memory.schemas.memory import MemoryMetadata
from datetime import datetime
return GraphContext(
results=[],
metadata=MemoryMetadata(
depth=depth or 1,
timeframe=timeframe,
generated_at=datetime.now().astimezone(),
primary_count=0,
related_count=0,
uri=migration_status, # Include status in metadata
),
)
project_url = active_project.project_url
response = await call_get(
+8 -1
View File
@@ -110,7 +110,14 @@ async def canvas(
# Write the file using the resource API
logger.info(f"Creating canvas file: {file_path} in project {project}")
response = await call_put(client, f"{project_url}/resource/{file_path}", json=canvas_json)
# Send canvas_json as content string, not as json parameter
# The resource endpoint expects Body() string content, not JSON-encoded data
response = await call_put(
client,
f"{project_url}/resource/{file_path}",
content=canvas_json,
headers={"Content-Type": "text/plain"},
)
# Parse response
result = response.json()
-8
View File
@@ -97,14 +97,6 @@ async def read_note(
)
return f"# Error\n\nIdentifier '{identifier}' is not allowed - paths must stay within project boundaries"
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before reading notes."
project_url = active_project.project_url
# Get the file via REST API - first try direct permalink lookup
+3 -3
View File
@@ -205,8 +205,8 @@ async def search_notes(
page: int = 1,
page_size: int = 10,
search_type: str = "text",
types: Optional[List[str]] = None,
entity_types: Optional[List[str]] = None,
types: List[str] = [],
entity_types: List[str] = [],
after_date: Optional[str] = None,
context: Context | None = None,
) -> SearchResponse | str:
@@ -345,7 +345,7 @@ async def search_notes(
else:
search_query.text = query # Default to text search
# Add optional filters if provided
# Add optional filters if provided (empty lists are treated as no filter)
if entity_types:
search_query.entity_types = [SearchItemType(t) for t in entity_types]
if types:
-261
View File
@@ -1,261 +0,0 @@
"""Sync status tool for Basic Memory MCP server."""
from typing import Optional
from loguru import logger
from fastmcp import Context
from basic_memory.config import ConfigManager
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_active_project
from basic_memory.services.sync_status_service import sync_status_tracker
def _get_all_projects_status() -> list[str]:
"""Get status lines for all configured projects."""
status_lines = []
try:
app_config = ConfigManager().config
if app_config.projects:
status_lines.extend(["", "---", "", "**All Projects Status:**"])
for project_name, project_path in app_config.projects.items():
# Check if this project has sync status
project_sync_status = sync_status_tracker.get_project_status(project_name)
if project_sync_status:
# Project has tracked sync activity
if project_sync_status.status.value == "watching":
# Project is actively watching for changes (steady state)
status_icon = "👁️"
status_text = "Watching for changes"
elif project_sync_status.status.value == "completed":
# Sync completed but not yet watching - transitional state
status_icon = ""
status_text = "Sync completed"
elif project_sync_status.status.value in ["scanning", "syncing"]:
status_icon = "🔄"
status_text = "Sync in progress"
if project_sync_status.files_total > 0:
progress_pct = (
project_sync_status.files_processed
/ project_sync_status.files_total
) * 100
status_text += f" ({project_sync_status.files_processed}/{project_sync_status.files_total}, {progress_pct:.0f}%)"
elif project_sync_status.status.value == "failed":
status_icon = ""
status_text = f"Sync error: {project_sync_status.error or 'Unknown error'}"
else:
status_icon = "⏸️"
status_text = project_sync_status.status.value.title()
else:
# Project has no tracked sync activity - will be synced automatically
status_icon = ""
status_text = "Pending sync"
status_lines.append(f"- {status_icon} **{project_name}**: {status_text}")
except Exception as e:
logger.debug(f"Could not get project config for comprehensive status: {e}")
return status_lines
@mcp.tool(
description="""Check the status of file synchronization and background operations.
Use this tool to:
- Check if file sync is in progress or completed
- Get detailed sync progress information
- Understand if your files are fully indexed
- Get specific error details if sync operations failed
- Monitor initial project setup and legacy migration
This covers all sync operations including:
- Initial project setup and file indexing
- Legacy project migration to unified database
- Ongoing file monitoring and updates
- Background processing of knowledge graphs
""",
)
async def sync_status(project: Optional[str] = None, context: Context | None = None) -> str:
"""Get current sync status and system readiness information.
This tool provides detailed information about any ongoing or completed
sync operations, helping users understand when their files are ready.
Args:
project: Optional project name to get project-specific context
Returns:
Formatted sync status with progress, readiness, and guidance
"""
logger.info("MCP tool call tool=sync_status")
async with get_client() as client:
status_lines = []
try:
from basic_memory.services.sync_status_service import sync_status_tracker
# Get overall summary
summary = sync_status_tracker.get_summary()
is_ready = sync_status_tracker.is_ready
# Header
status_lines.extend(
[
"# Basic Memory Sync Status",
"",
f"**Current Status**: {summary}",
f"**System Ready**: {'✅ Yes' if is_ready else '🔄 Processing'}",
"",
]
)
if is_ready:
status_lines.extend(
[
"✅ **All sync operations completed**",
"",
"- File indexing is complete",
"- Knowledge graphs are up to date",
"- All Basic Memory tools are fully operational",
"",
"Your knowledge base is ready for use!",
]
)
# Show all projects status even when ready
status_lines.extend(_get_all_projects_status())
else:
# System is still processing - show both active and all projects
all_sync_projects = sync_status_tracker.get_all_projects()
active_projects = [
p
for p in all_sync_projects.values()
if p.status.value in ["scanning", "syncing"]
]
failed_projects = [
p for p in all_sync_projects.values() if p.status.value == "failed"
]
if active_projects:
status_lines.extend(
[
"🔄 **File synchronization in progress**",
"",
"Basic Memory is automatically processing all configured projects and building knowledge graphs.",
"This typically takes 1-3 minutes depending on the amount of content.",
"",
"**Currently Processing:**",
]
)
for project_status in active_projects:
progress = ""
if project_status.files_total > 0:
progress_pct = (
project_status.files_processed / project_status.files_total
) * 100
progress = f" ({project_status.files_processed}/{project_status.files_total}, {progress_pct:.0f}%)"
status_lines.append(
f"- **{project_status.project_name}**: {project_status.message}{progress}"
)
status_lines.extend(
[
"",
"**What's happening:**",
"- Scanning and indexing markdown files",
"- Building entity and relationship graphs",
"- Settings up full-text search indexes",
"- Processing file changes and updates",
"",
"**What you can do:**",
"- Wait for automatic processing to complete - no action needed",
"- Use this tool again to check progress",
"- Simple operations may work already",
"- All projects will be available once sync finishes",
]
)
# Handle failed projects (independent of active projects)
if failed_projects:
status_lines.extend(["", "❌ **Some projects failed to sync:**", ""])
for project_status in failed_projects:
status_lines.append(
f"- **{project_status.project_name}**: {project_status.error or 'Unknown error'}"
)
status_lines.extend(
[
"",
"**Next steps:**",
"1. Check the logs for detailed error information",
"2. Ensure file permissions allow read/write access",
"3. Try restarting the MCP server",
"4. If issues persist, consider filing a support issue",
]
)
elif not active_projects:
# No active or failed projects - must be pending
status_lines.extend(
[
"⏳ **Sync operations pending**",
"",
"File synchronization has been queued but hasn't started yet.",
"This usually resolves automatically within a few seconds.",
]
)
# Add comprehensive project status for all configured projects
all_projects_status = _get_all_projects_status()
if all_projects_status:
status_lines.extend(all_projects_status)
# Add explanation about automatic syncing if there are unsynced projects
unsynced_count = sum(1 for line in all_projects_status if "" in line)
if unsynced_count > 0 and not is_ready:
status_lines.extend(
[
"",
"**Note**: All configured projects will be automatically synced during startup.",
]
)
# Add project context if provided
if project:
try:
active_project = await get_active_project(client, project, context)
status_lines.extend(
[
"",
"---",
"",
f"**Active Project**: {active_project.name}",
f"**Project Path**: {active_project.home}",
]
)
except Exception as e:
logger.debug(f"Could not get project info: {e}")
return "\n".join(status_lines)
except Exception as e:
return f"""# Sync Status - Error
❌ **Unable to check sync status**: {str(e)}
**Troubleshooting:**
- The system may still be starting up
- Try waiting a few seconds and checking again
- Check logs for detailed error information
- Consider restarting if the issue persists
"""
-70
View File
@@ -510,73 +510,3 @@ async def call_delete(
except HTTPStatusError as e:
raise ToolError(error_message) from e
def check_migration_status() -> Optional[str]:
"""Check if sync/migration is in progress and return status message if so.
Returns:
Status message if sync is in progress, None if system is ready
"""
try:
from basic_memory.services.sync_status_service import sync_status_tracker
if not sync_status_tracker.is_ready:
return sync_status_tracker.get_summary()
return None
except Exception:
# If there's any error checking sync status, assume ready
return None
async def wait_for_migration_or_return_status(
timeout: float = 5.0, project_name: Optional[str] = None
) -> Optional[str]:
"""Wait briefly for sync/migration to complete, or return status message.
Args:
timeout: Maximum time to wait for sync completion
project_name: Optional project name to check specific project status.
If provided, only checks that project's readiness.
If None, uses global status check (legacy behavior).
Returns:
Status message if sync is still in progress, None if ready
"""
try:
from basic_memory.services.sync_status_service import sync_status_tracker
import asyncio
# Check if we should use project-specific or global status
def is_ready() -> bool:
if project_name:
return sync_status_tracker.is_project_ready(project_name)
return sync_status_tracker.is_ready
if is_ready():
return None
# Wait briefly for sync to complete
start_time = asyncio.get_event_loop().time()
while (asyncio.get_event_loop().time() - start_time) < timeout:
if is_ready():
return None
await asyncio.sleep(0.1) # Check every 100ms
# Still not ready after timeout
if project_name:
# For project-specific checks, get project status details
project_status = sync_status_tracker.get_project_status(project_name)
if project_status and project_status.status.value == "failed":
error_msg = project_status.error or "Unknown sync error"
return f"❌ Sync failed for project '{project_name}': {error_msg}"
elif project_status:
return f"🔄 Project '{project_name}' is still syncing: {project_status.message}"
else:
return f"⚠️ Project '{project_name}' status unknown"
else:
# Fall back to global summary for legacy calls
return sync_status_tracker.get_summary()
except Exception: # pragma: no cover
# If there's any error, assume ready
return None
+7 -18
View File
@@ -16,9 +16,6 @@ from basic_memory.utils import parse_tags, validate_project_path
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
@mcp.tool(
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
@@ -28,8 +25,8 @@ async def write_note(
content: str,
folder: str,
project: Optional[str] = None,
tags=None,
entity_type: str = "note",
tags: list[str] | str | None = None,
note_type: str = "note",
context: Context | None = None,
) -> str:
"""Write a markdown note to the knowledge base.
@@ -70,7 +67,8 @@ async def write_note(
available projects.
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
entity_type: Type of entity to create. Defaults to "note". Can be "guide", "report", "config", etc.
note_type: Type of note to create (stored in frontmatter). Defaults to "note".
Can be "guide", "report", "config", "person", etc.
context: Optional FastMCP context for performance caching.
Returns:
@@ -96,14 +94,14 @@ async def write_note(
content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
)
# Create a note with tags and entity type
# Create a note with tags and note type
write_note(
project="work-project",
title="API Design",
folder="specs",
content="# REST API Specification\\n\\n- implements [[Authentication]]",
tags=["api", "design"],
entity_type="guide"
note_type="guide"
)
# Update existing note (same title/folder)
@@ -140,15 +138,6 @@ async def write_note(
)
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
# Check migration status and wait briefly if needed
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
migration_status = await wait_for_migration_or_return_status(
timeout=5.0, project_name=active_project.name
)
if migration_status: # pragma: no cover
return f"# System Status\n\n{migration_status}\n\nPlease wait for migration to complete before creating notes."
# Process tags using the helper function
tag_list = parse_tags(tags)
# Create the entity request
@@ -156,7 +145,7 @@ async def write_note(
entity = Entity(
title=title,
folder=folder,
entity_type=entity_type,
entity_type=note_type,
content_type="text/markdown",
content=content,
entity_metadata=metadata,
+7
View File
@@ -13,6 +13,7 @@ from sqlalchemy import (
DateTime,
Index,
JSON,
Float,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -73,6 +74,12 @@ class Entity(Base):
# checksum of file
checksum: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# File metadata for sync
# mtime: file modification timestamp (Unix epoch float) for change detection
mtime: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# size: file size in bytes for quick change detection
size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Metadata and tracking
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now().astimezone()
+5
View File
@@ -9,6 +9,7 @@ from sqlalchemy import (
Text,
Boolean,
DateTime,
Float,
Index,
event,
)
@@ -61,6 +62,10 @@ class Project(Base):
onupdate=lambda: datetime.now(UTC),
)
# Sync optimization - scan watermark tracking
last_scan_timestamp: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
last_file_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Define relationships to entities, observations, and relations
# These relationships will be established once we add project_id to those models
entities = relationship("Entity", back_populates="project", cascade="all, delete-orphan")
@@ -3,6 +3,7 @@
from pathlib import Path
from typing import List, Optional, Sequence, Union
from loguru import logger
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -62,6 +63,23 @@ class EntityRepository(Repository[Entity]):
)
return await self.find_one(query)
async def find_by_checksum(self, checksum: str) -> Sequence[Entity]:
"""Find entities with the given checksum.
Used for move detection - finds entities that may have been moved to a new path.
Multiple entities may have the same checksum if files were copied.
Args:
checksum: File content checksum to search for
Returns:
Sequence of entities with matching checksum (may be empty)
"""
query = self.select().where(Entity.checksum == checksum)
# Don't load relationships for move detection - we only need file_path and checksum
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def delete_by_file_path(self, file_path: Union[Path, str]) -> bool:
"""Delete entity with the provided file_path.
@@ -135,7 +153,20 @@ class EntityRepository(Repository[Entity]):
)
return found
except IntegrityError:
except IntegrityError as e:
# Check if this is a FOREIGN KEY constraint failure
error_str = str(e)
if "FOREIGN KEY constraint failed" in error_str:
# Import locally to avoid circular dependency (repository -> services -> repository)
from basic_memory.services.exceptions import SyncFatalError
# Project doesn't exist in database - this is a fatal sync error
raise SyncFatalError(
f"Cannot sync file '{entity.file_path}': "
f"project_id={entity.project_id} does not exist in database. "
f"The project may have been deleted. This sync will be terminated."
) from e
await session.rollback()
# Re-query after rollback to get a fresh, attached entity
@@ -150,25 +181,32 @@ class EntityRepository(Repository[Entity]):
if existing_entity:
# File path conflict - update the existing entity
for key, value in {
"title": entity.title,
"entity_type": entity.entity_type,
"entity_metadata": entity.entity_metadata,
"content_type": entity.content_type,
"permalink": entity.permalink,
"checksum": entity.checksum,
"updated_at": entity.updated_at,
}.items():
setattr(existing_entity, key, value)
logger.debug(
f"Resolving file_path conflict for {entity.file_path}, "
f"entity_id={existing_entity.id}, observations={len(entity.observations)}"
)
# Use merge to avoid session state conflicts
# Set the ID to update existing entity
entity.id = existing_entity.id
# Clear and re-add observations
existing_entity.observations.clear()
# Ensure observations reference the correct entity_id
for obs in entity.observations:
obs.entity_id = existing_entity.id
existing_entity.observations.append(obs)
# Clear any existing ID to force INSERT as new observation
obs.id = None
# Merge the entity which will update the existing one
merged_entity = await session.merge(entity)
await session.commit()
return existing_entity
# Re-query to get proper relationships loaded
final_result = await session.execute(
select(Entity)
.where(Entity.id == merged_entity.id)
.options(*self.get_load_options())
)
return final_result.scalar_one()
else:
# No file_path conflict - must be permalink conflict
@@ -176,6 +214,21 @@ class EntityRepository(Repository[Entity]):
entity = await self._handle_permalink_conflict(entity, session)
return entity
async def get_all_file_paths(self) -> List[str]:
"""Get all file paths for this project - optimized for deletion detection.
Returns only file_path strings without loading entities or relationships.
Used by streaming sync to detect deleted files efficiently.
Returns:
List of file_path strings for all entities in the project
"""
query = select(Entity.file_path)
query = self._add_project_filter(query)
result = await self.execute_query(query, use_query_options=False)
return list(result.scalars().all())
async def get_distinct_directories(self) -> List[str]:
"""Extract unique directory paths from file_path column.
+25 -1
View File
@@ -1,6 +1,7 @@
"""Pydantic schemas for sync report responses."""
from typing import TYPE_CHECKING, Dict, Set
from datetime import datetime
from typing import TYPE_CHECKING, Dict, List, Set
from pydantic import BaseModel, Field
@@ -9,6 +10,17 @@ if TYPE_CHECKING:
from basic_memory.sync.sync_service import SyncReport
class SkippedFileResponse(BaseModel):
"""Information about a file that was skipped due to repeated failures."""
path: str = Field(description="File path relative to project root")
reason: str = Field(description="Error message from last failure")
failure_count: int = Field(description="Number of consecutive failures")
first_failed: datetime = Field(description="Timestamp of first failure")
model_config = {"from_attributes": True}
class SyncReportResponse(BaseModel):
"""Report of file changes found compared to database state.
@@ -24,6 +36,9 @@ class SyncReportResponse(BaseModel):
checksums: Dict[str, str] = Field(
default_factory=dict, description="Current file checksums (path -> checksum)"
)
skipped_files: List[SkippedFileResponse] = Field(
default_factory=list, description="Files skipped due to repeated failures"
)
total: int = Field(description="Total number of changes")
@classmethod
@@ -42,6 +57,15 @@ class SyncReportResponse(BaseModel):
deleted=report.deleted,
moves=report.moves,
checksums=report.checksums,
skipped_files=[
SkippedFileResponse(
path=skipped.path,
reason=skipped.reason,
failure_count=skipped.failure_count,
first_failed=skipped.first_failed,
)
for skipped in report.skipped_files
],
total=report.total,
)
+8 -1
View File
@@ -623,7 +623,7 @@ class EntityService(BaseService[EntityModel]):
Args:
current_content: The current markdown content
section_header: The section header to find and replace (e.g., "## Section Name")
new_content: The new content to replace the section with
new_content: The new content to replace the section with (should not include the header itself)
Returns:
The updated content with the section replaced
@@ -635,6 +635,13 @@ class EntityService(BaseService[EntityModel]):
if not section_header.startswith("#"):
section_header = "## " + section_header
# Strip duplicate header from new_content if present (fix for issue #390)
# LLMs sometimes include the section header in their content, which would create duplicates
new_content_lines = new_content.lstrip().split("\n")
if new_content_lines and new_content_lines[0].strip() == section_header.strip():
# Remove the duplicate header line
new_content = "\n".join(new_content_lines[1:]).lstrip()
# First pass: count matching sections to check for duplicates
lines = current_content.split("\n")
matching_sections = []
+15
View File
@@ -20,3 +20,18 @@ class DirectoryOperationError(Exception):
"""Raised when directory operations fail"""
pass
class SyncFatalError(Exception):
"""Raised when sync encounters a fatal error that prevents continuation.
Fatal errors include:
- Project deleted during sync (FOREIGN KEY constraint)
- Database corruption
- Critical system failures
When this exception is raised, the entire sync operation should be terminated
immediately rather than attempting to continue with remaining files.
"""
pass
+162 -24
View File
@@ -1,12 +1,17 @@
"""Service for file operations with checksum tracking."""
import asyncio
import hashlib
import mimetypes
from os import stat_result
from pathlib import Path
from typing import Any, Dict, Tuple, Union
import aiofiles
import yaml
from basic_memory import file_utils
from basic_memory.file_utils import FileError
from basic_memory.file_utils import FileError, ParseError
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.models import Entity as EntityModel
from basic_memory.schemas import Entity as EntitySchema
@@ -16,13 +21,15 @@ from loguru import logger
class FileService:
"""Service for handling file operations.
"""Service for handling file operations with concurrency control.
All paths are handled as Path objects internally. Strings are converted to
Path objects when passed in. Relative paths are assumed to be relative to
base_path.
Features:
- True async I/O with aiofiles (non-blocking)
- Built-in concurrency limits (semaphore)
- Consistent file writing with checksums
- Frontmatter management
- Atomic operations
@@ -33,9 +40,13 @@ class FileService:
self,
base_path: Path,
markdown_processor: MarkdownProcessor,
max_concurrent_files: int = 10,
):
self.base_path = base_path.resolve() # Get absolute path
self.markdown_processor = markdown_processor
# Semaphore to limit concurrent file operations
# Prevents OOM on large projects by processing files in batches
self._file_semaphore = asyncio.Semaphore(max_concurrent_files)
def get_entity_path(self, entity: Union[EntityModel, EntitySchema]) -> Path:
"""Generate absolute filesystem path for entity.
@@ -104,6 +115,33 @@ class FileService:
logger.error("Failed to check file existence", path=str(path), error=str(e))
raise FileOperationError(f"Failed to check file existence: {e}")
async def ensure_directory(self, path: FilePath) -> None:
"""Ensure directory exists, creating if necessary.
Uses semaphore to control concurrency for directory creation operations.
Args:
path: Directory path to ensure (Path or string)
Raises:
FileOperationError: If directory creation fails
"""
try:
# Convert string to Path if needed
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
# Use semaphore for concurrency control
async with self._file_semaphore:
# Run blocking mkdir in thread pool
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, lambda: full_path.mkdir(parents=True, exist_ok=True)
)
except Exception as e: # pragma: no cover
logger.error("Failed to create directory", path=str(path), error=str(e))
raise FileOperationError(f"Failed to create directory {path}: {e}")
async def write_file(self, path: FilePath, content: str) -> str:
"""Write content to file and return checksum.
@@ -126,7 +164,7 @@ class FileService:
try:
# Ensure parent directory exists
await file_utils.ensure_directory(full_path.parent)
await self.ensure_directory(full_path.parent)
# Write content atomically
logger.info(
@@ -147,9 +185,45 @@ class FileService:
logger.exception("File write error", path=str(full_path), error=str(e))
raise FileOperationError(f"Failed to write file: {e}")
# TODO remove read_file
async def read_file_content(self, path: FilePath) -> str:
"""Read file content using true async I/O with aiofiles.
Handles both absolute and relative paths. Relative paths are resolved
against base_path.
Args:
path: Path to read (Path or string)
Returns:
File content as string
Raises:
FileOperationError: If read fails
"""
# Convert string to Path if needed
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
logger.debug("Reading file content", operation="read_file_content", path=str(full_path))
async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
content = await f.read()
logger.debug(
"File read completed",
path=str(full_path),
content_length=len(content),
)
return content
except Exception as e:
logger.exception("File read error", path=str(full_path), error=str(e))
raise FileOperationError(f"Failed to read file: {e}")
async def read_file(self, path: FilePath) -> Tuple[str, str]:
"""Read file and compute checksum.
"""Read file and compute checksum using true async I/O.
Uses aiofiles for non-blocking file reads.
Handles both absolute and relative paths. Relative paths are resolved
against base_path.
@@ -169,7 +243,11 @@ class FileService:
try:
logger.debug("Reading file", operation="read_file", path=str(full_path))
content = full_path.read_text(encoding="utf-8")
# Use aiofiles for non-blocking read
async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
content = await f.read()
checksum = await file_utils.compute_checksum(content)
logger.debug(
@@ -199,29 +277,85 @@ class FileService:
full_path.unlink(missing_ok=True)
async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
"""
Update frontmatter fields in a file while preserving all content.
"""Update frontmatter fields in a file while preserving all content.
Only modifies the frontmatter section, leaving all content untouched.
Creates frontmatter section if none exists.
Returns checksum of updated file.
Uses aiofiles for true async I/O (non-blocking).
Args:
path: Path to the file (Path or string)
updates: Dictionary of frontmatter fields to update
path: Path to markdown file (Path or string)
updates: Dict of frontmatter fields to update
Returns:
Checksum of updated file
Raises:
FileOperationError: If file operations fail
ParseError: If frontmatter parsing fails
"""
# Convert string to Path if needed
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
return await file_utils.update_frontmatter(full_path, updates)
try:
# Read current content using aiofiles
async with aiofiles.open(full_path, mode="r", encoding="utf-8") as f:
content = await f.read()
# Parse current frontmatter with proper error handling for malformed YAML
current_fm = {}
if file_utils.has_frontmatter(content):
try:
current_fm = file_utils.parse_frontmatter(content)
content = file_utils.remove_frontmatter(content)
except (ParseError, yaml.YAMLError) as e:
# Log warning and treat as plain markdown without frontmatter
logger.warning(
f"Failed to parse YAML frontmatter in {full_path}: {e}. "
"Treating file as plain markdown without frontmatter."
)
# Keep full content, treat as having no frontmatter
current_fm = {}
# Update frontmatter
new_fm = {**current_fm, **updates}
# Write new file with updated frontmatter
yaml_fm = yaml.dump(new_fm, sort_keys=False, allow_unicode=True)
final_content = f"---\n{yaml_fm}---\n\n{content.strip()}"
logger.debug(
"Updating frontmatter", path=str(full_path), update_keys=list(updates.keys())
)
await file_utils.write_file_atomic(full_path, final_content)
return await file_utils.compute_checksum(final_content)
except Exception as e:
# Only log real errors (not YAML parsing, which is handled above)
if not isinstance(e, (ParseError, yaml.YAMLError)):
logger.error(
"Failed to update frontmatter",
path=str(full_path),
error=str(e),
)
raise FileOperationError(f"Failed to update frontmatter: {e}")
async def compute_checksum(self, path: FilePath) -> str:
"""Compute checksum for a file.
"""Compute checksum for a file using true async I/O.
Uses aiofiles for non-blocking I/O with 64KB chunked reading.
Semaphore limits concurrent file operations to prevent OOM.
Memory usage is constant regardless of file size.
Args:
path: Path to the file (Path or string)
Returns:
Checksum of the file content
SHA256 checksum hex string
Raises:
FileError: If checksum computation fails
@@ -230,18 +364,22 @@ class FileService:
path_obj = self.base_path / path if isinstance(path, str) else path
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
try:
if self.is_markdown(path):
# read str
content = full_path.read_text(encoding="utf-8")
else:
# read bytes
content = full_path.read_bytes()
return await file_utils.compute_checksum(content)
# Semaphore controls concurrency - max N files processed at once
async with self._file_semaphore:
try:
hasher = hashlib.sha256()
chunk_size = 65536 # 64KB chunks
except Exception as e: # pragma: no cover
logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
raise FileError(f"Failed to compute checksum for {path}: {e}")
# async I/O with aiofiles
async with aiofiles.open(full_path, mode="rb") as f:
while chunk := await f.read(chunk_size):
hasher.update(chunk)
return hasher.hexdigest()
except Exception as e: # pragma: no cover
logger.error("Failed to compute checksum", path=str(full_path), error=str(e))
raise FileError(f"Failed to compute checksum for {path}: {e}")
def file_stats(self, path: FilePath) -> stat_result:
"""Return file stats for a given path.
@@ -118,18 +118,8 @@ async def initialize_file_sync(
sync_dir = Path(project.path)
await sync_service.sync(sync_dir, project_name=project.name)
logger.info(f"Background sync completed successfully for project: {project.name}")
# Mark project as watching for changes after successful sync
from basic_memory.services.sync_status_service import sync_status_tracker
sync_status_tracker.start_project_watch(project.name)
logger.info(f"Project {project.name} is now watching for changes")
except Exception as e: # pragma: no cover
logger.error(f"Error in background sync for project {project.name}: {e}")
# Mark sync as failed for this project
from basic_memory.services.sync_status_service import sync_status_tracker
sync_status_tracker.fail_project_sync(project.name, str(e))
# Create background tasks for all project syncs (non-blocking)
sync_tasks = [
+44 -10
View File
@@ -1,7 +1,9 @@
"""Project management service for Basic Memory."""
import asyncio
import json
import os
import shutil
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Sequence
@@ -219,11 +221,12 @@ class ProjectService:
logger.info(f"Project '{name}' added at {resolved_path}")
async def remove_project(self, name: str) -> None:
async def remove_project(self, name: str, delete_notes: bool = False) -> None:
"""Remove a project from configuration and database.
Args:
name: The name of the project to remove
delete_notes: If True, delete the project directory from filesystem
Raises:
ValueError: If the project doesn't exist or is the default project
@@ -231,16 +234,43 @@ class ProjectService:
if not self.repository: # pragma: no cover
raise ValueError("Repository is required for remove_project")
# First remove from config (this will validate the project exists and is not default)
self.config_manager.remove_project(name)
# Then remove from database using robust lookup
# Get project from database first
project = await self.get_project(name)
if project:
await self.repository.delete(project.id)
if not project:
raise ValueError(f"Project '{name}' not found")
project_path = project.path
# Check if project is default (in cloud mode, check database; in local mode, check config)
if project.is_default or name == self.config_manager.config.default_project:
raise ValueError(f"Cannot remove the default project '{name}'")
# Remove from config if it exists there (may not exist in cloud mode)
try:
self.config_manager.remove_project(name)
except ValueError:
# Project not in config - that's OK in cloud mode, continue with database deletion
logger.debug(f"Project '{name}' not found in config, removing from database only")
# Remove from database
await self.repository.delete(project.id)
logger.info(f"Project '{name}' removed from configuration and database")
# Optionally delete the project directory
if delete_notes and project_path:
try:
path_obj = Path(project_path)
if path_obj.exists() and path_obj.is_dir():
await asyncio.to_thread(shutil.rmtree, project_path)
logger.info(f"Deleted project directory: {project_path}")
else:
logger.warning(
f"Project directory not found or not a directory: {project_path}"
)
except Exception as e:
logger.warning(f"Failed to delete project directory {project_path}: {e}")
async def set_default_project(self, name: str) -> None:
"""Set the default project in configuration and database.
@@ -360,11 +390,15 @@ class ProjectService:
}
await self.repository.create(project_data)
# Add projects that exist in DB but not in config to config
# Remove projects that exist in DB but not in config
# Config is the source of truth - if a project was deleted from config,
# it should be deleted from DB too (fixes issue #193)
for name, project in db_projects_by_permalink.items():
if name not in config_projects:
logger.info(f"Adding project '{name}' to configuration")
self.config_manager.add_project(name, project.path)
logger.info(
f"Removing project '{name}' from database (deleted from config, source of truth)"
)
await self.repository.delete(project.id)
# Ensure database default project state is consistent
await self._ensure_single_default_project()
@@ -1,198 +0,0 @@
"""Simple sync status tracking service."""
from dataclasses import dataclass
from enum import Enum
from typing import Dict, Optional
class SyncStatus(Enum):
"""Status of sync operations."""
IDLE = "idle"
SCANNING = "scanning"
SYNCING = "syncing"
COMPLETED = "completed"
FAILED = "failed"
WATCHING = "watching"
@dataclass
class ProjectSyncStatus:
"""Sync status for a single project."""
project_name: str
status: SyncStatus
message: str = ""
files_total: int = 0
files_processed: int = 0
error: Optional[str] = None
class SyncStatusTracker:
"""Global tracker for all sync operations."""
def __init__(self):
self._project_statuses: Dict[str, ProjectSyncStatus] = {}
self._global_status: SyncStatus = SyncStatus.IDLE
def start_project_sync(self, project_name: str, files_total: int = 0) -> None:
"""Start tracking sync for a project."""
self._project_statuses[project_name] = ProjectSyncStatus(
project_name=project_name,
status=SyncStatus.SCANNING,
message="Scanning files",
files_total=files_total,
files_processed=0,
)
self._update_global_status()
def update_project_progress( # pragma: no cover
self,
project_name: str,
status: SyncStatus,
message: str = "",
files_processed: int = 0,
files_total: Optional[int] = None,
) -> None:
"""Update progress for a project."""
if project_name not in self._project_statuses: # pragma: no cover
return
project_status = self._project_statuses[project_name]
project_status.status = status
project_status.message = message
project_status.files_processed = files_processed
if files_total is not None:
project_status.files_total = files_total
self._update_global_status()
def complete_project_sync(self, project_name: str) -> None:
"""Mark project sync as completed."""
if project_name in self._project_statuses:
self._project_statuses[project_name].status = SyncStatus.COMPLETED
self._project_statuses[project_name].message = "Sync completed"
self._update_global_status()
def fail_project_sync(self, project_name: str, error: str) -> None:
"""Mark project sync as failed."""
if project_name in self._project_statuses:
self._project_statuses[project_name].status = SyncStatus.FAILED
self._project_statuses[project_name].error = error
self._update_global_status()
def start_project_watch(self, project_name: str) -> None:
"""Mark project as watching for changes (steady state after sync)."""
if project_name in self._project_statuses:
self._project_statuses[project_name].status = SyncStatus.WATCHING
self._project_statuses[project_name].message = "Watching for changes"
self._update_global_status()
else:
# Create new status if project isn't tracked yet
self._project_statuses[project_name] = ProjectSyncStatus(
project_name=project_name,
status=SyncStatus.WATCHING,
message="Watching for changes",
files_total=0,
files_processed=0,
)
self._update_global_status()
def _update_global_status(self) -> None:
"""Update global status based on project statuses."""
if not self._project_statuses: # pragma: no cover
self._global_status = SyncStatus.IDLE
return
statuses = [p.status for p in self._project_statuses.values()]
if any(s == SyncStatus.FAILED for s in statuses):
self._global_status = SyncStatus.FAILED
elif any(s in (SyncStatus.SCANNING, SyncStatus.SYNCING) for s in statuses):
self._global_status = SyncStatus.SYNCING
elif all(s in (SyncStatus.COMPLETED, SyncStatus.WATCHING) for s in statuses):
self._global_status = SyncStatus.COMPLETED
else:
self._global_status = SyncStatus.SYNCING
@property
def global_status(self) -> SyncStatus:
"""Get overall sync status."""
return self._global_status
@property
def is_syncing(self) -> bool:
"""Check if any sync operation is in progress."""
return self._global_status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
@property
def is_ready(self) -> bool: # pragma: no cover
"""Check if system is ready (no sync in progress)."""
return self._global_status in (SyncStatus.IDLE, SyncStatus.COMPLETED)
def is_project_ready(self, project_name: str) -> bool:
"""Check if a specific project is ready for operations.
Args:
project_name: Name of the project to check
Returns:
True if the project is ready (completed, watching, or not tracked),
False if the project is syncing, scanning, or failed
"""
project_status = self._project_statuses.get(project_name)
if not project_status:
# Project not tracked = ready (likely hasn't been synced yet)
return True
return project_status.status in (SyncStatus.COMPLETED, SyncStatus.WATCHING, SyncStatus.IDLE)
def get_project_status(self, project_name: str) -> Optional[ProjectSyncStatus]:
"""Get status for a specific project."""
return self._project_statuses.get(project_name)
def get_all_projects(self) -> Dict[str, ProjectSyncStatus]:
"""Get all project statuses."""
return self._project_statuses.copy()
def get_summary(self) -> str: # pragma: no cover
"""Get a user-friendly summary of sync status."""
if self._global_status == SyncStatus.IDLE:
return "✅ System ready"
elif self._global_status == SyncStatus.COMPLETED:
return "✅ All projects synced successfully"
elif self._global_status == SyncStatus.FAILED:
failed_projects = [
p.project_name
for p in self._project_statuses.values()
if p.status == SyncStatus.FAILED
]
return f"❌ Sync failed for: {', '.join(failed_projects)}"
else:
active_projects = [
p.project_name
for p in self._project_statuses.values()
if p.status in (SyncStatus.SCANNING, SyncStatus.SYNCING)
]
total_files = sum(p.files_total for p in self._project_statuses.values())
processed_files = sum(p.files_processed for p in self._project_statuses.values())
if total_files > 0:
progress_pct = (processed_files / total_files) * 100
return f"🔄 Syncing {len(active_projects)} projects ({processed_files}/{total_files} files, {progress_pct:.0f}%)"
else:
return f"🔄 Syncing {len(active_projects)} projects"
def clear_completed(self) -> None:
"""Remove completed project statuses to clean up memory."""
self._project_statuses = {
name: status
for name, status in self._project_statuses.items()
if status.status != SyncStatus.COMPLETED
}
self._update_global_status()
# Global sync status tracker instance
sync_status_tracker = SyncStatusTracker()
File diff suppressed because it is too large Load Diff
+14
View File
@@ -236,6 +236,20 @@ class WatchService:
# avoid circular imports
from basic_memory.sync.sync_service import get_sync_service
# Check if project still exists in configuration before processing
# This prevents deleted projects from being recreated by background sync
from basic_memory.config import ConfigManager
config_manager = ConfigManager()
if (
project.name not in config_manager.projects
and project.permalink not in config_manager.projects
):
logger.info(
f"Skipping sync for deleted project: {project.name}, change_count={len(changes)}"
)
return
sync_service = await get_sync_service(project)
file_service = sync_service.file_service
+58
View File
@@ -13,6 +13,49 @@ from loguru import logger
from unidecode import unidecode
def normalize_project_path(path: str) -> str:
"""Normalize project path by stripping mount point prefix.
In cloud deployments, the S3 bucket is mounted at /app/data. We strip this
prefix from project paths to avoid leaking implementation details and to
ensure paths match the actual S3 bucket structure.
For local paths (including Windows paths), returns the path unchanged.
Args:
path: Project path (e.g., "/app/data/basic-memory-llc" or "C:\\Users\\...")
Returns:
Normalized path (e.g., "/basic-memory-llc" or "C:\\Users\\...")
Examples:
>>> normalize_project_path("/app/data/my-project")
'/my-project'
>>> normalize_project_path("/my-project")
'/my-project'
>>> normalize_project_path("app/data/my-project")
'/my-project'
>>> normalize_project_path("C:\\\\Users\\\\project")
'C:\\\\Users\\\\project'
"""
# Check if this is a Windows absolute path (e.g., C:\Users\...)
# Windows paths have a drive letter followed by a colon
if len(path) >= 2 and path[1] == ":":
# Windows absolute path - return unchanged
return path
# Handle both absolute and relative Unix paths
normalized = path.lstrip("/")
if normalized.startswith("app/data/"):
normalized = normalized.removeprefix("app/data/")
# Ensure leading slash for Unix absolute paths
if not normalized.startswith("/"):
normalized = "/" + normalized
return normalized
@runtime_checkable
class PathLike(Protocol):
"""Protocol for objects that can be used as paths."""
@@ -185,6 +228,21 @@ def setup_logging(
logger.info(f"ENV: '{env}' Log level: '{log_level}' Logging to {log_file}")
# Bind environment context for structured logging (works in both local and cloud)
tenant_id = os.getenv("BASIC_MEMORY_TENANT_ID", "local")
fly_app_name = os.getenv("FLY_APP_NAME", "local")
fly_machine_id = os.getenv("FLY_MACHINE_ID", "local")
fly_region = os.getenv("FLY_REGION", "local")
logger.configure(
extra={
"tenant_id": tenant_id,
"fly_app_name": fly_app_name,
"fly_machine_id": fly_machine_id,
"fly_region": fly_region,
}
)
# Reduce noise from third-party libraries
noisy_loggers = {
# HTTP client logs
@@ -19,7 +19,7 @@ def test_project_list(app_config, test_project, config_manager):
print(f"Exception: {result.exception}")
assert result.exit_code == 0
assert "test-project" in result.stdout
assert "" in result.stdout # default marker
assert "[X]" in result.stdout # default marker
def test_project_info(app_config, test_project, config_manager):
@@ -114,8 +114,8 @@ def test_project_set_default(app_config, config_manager):
# Verify in list
result = runner.invoke(app, ["project", "list"])
assert result.exit_code == 0
# The new project should have the checkmark now
# The new project should have the [X] marker now
lines = result.stdout.split("\n")
for line in lines:
if "another-project" in line:
assert "" in line
assert "[X]" in line
@@ -1,61 +0,0 @@
"""Integration tests for sync CLI commands."""
from pathlib import Path
from typer.testing import CliRunner
from basic_memory.cli.main import app
def test_sync_command(app_config, test_project, config_manager, config_home):
"""Test 'bm sync' command successfully syncs files."""
runner = CliRunner()
# Create a test file
test_file = Path(config_home) / "test-note.md"
test_file.write_text("# Test Note\n\nThis is a test.")
# Run sync
result = runner.invoke(app, ["sync", "--project", "test-project"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
assert "sync" in result.stdout.lower() or "initiated" in result.stdout.lower()
def test_status_command(app_config, test_project, config_manager, config_home):
"""Test 'bm status' command shows sync status."""
runner = CliRunner()
# Create a test file
test_file = Path(config_home) / "unsynced.md"
test_file.write_text("# Unsynced Note\n\nThis file hasn't been synced yet.")
# Run status
result = runner.invoke(app, ["status", "--project", "test-project"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
# Should show some status output
assert len(result.stdout) > 0
def test_status_verbose(app_config, test_project, config_manager, config_home):
"""Test 'bm status --verbose' shows detailed status."""
runner = CliRunner()
# Create a test file
test_file = Path(config_home) / "test.md"
test_file.write_text("# Test\n\nContent.")
# Run status with verbose
result = runner.invoke(app, ["status", "--project", "test-project", "--verbose"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
assert len(result.stdout) > 0
@@ -8,6 +8,7 @@ from basic_memory.repository import (
EntityRepository,
ObservationRepository,
RelationRepository,
ProjectRepository,
)
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.schemas import Entity as EntitySchema
@@ -88,6 +89,7 @@ async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory):
observation_repository = ObservationRepository(session_maker, project_id=1)
relation_repository = RelationRepository(session_maker, project_id=1)
search_repository = SearchRepository(session_maker, project_id=1)
project_repository = ProjectRepository(session_maker)
# Setup services
entity_parser = EntityParser(tmp_path)
@@ -110,6 +112,7 @@ async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory):
sync_service = SyncService(
app_config=app_config,
entity_service=entity_service,
project_repository=project_repository,
entity_parser=entity_parser,
entity_repository=entity_repository,
relation_repository=relation_repository,
+365
View File
@@ -466,6 +466,40 @@ async def test_sync_project_endpoint(test_graph, client, project_url):
assert "Filesystem sync initiated" in data["message"]
@pytest.mark.asyncio
async def test_sync_project_endpoint_with_force_full(test_graph, client, project_url):
"""Test the project sync endpoint with force_full parameter."""
# Call the sync endpoint with force_full=true
response = await client.post(f"{project_url}/project/sync?force_full=true")
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "status" in data
assert "message" in data
assert data["status"] == "sync_started"
assert "Filesystem sync initiated" in data["message"]
@pytest.mark.asyncio
async def test_sync_project_endpoint_with_force_full_false(test_graph, client, project_url):
"""Test the project sync endpoint with force_full=false."""
# Call the sync endpoint with force_full=false
response = await client.post(f"{project_url}/project/sync?force_full=false")
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "status" in data
assert "message" in data
assert data["status"] == "sync_started"
assert "Filesystem sync initiated" in data["message"]
@pytest.mark.asyncio
async def test_sync_project_endpoint_not_found(client):
"""Test the project sync endpoint with nonexistent project."""
@@ -474,3 +508,334 @@ async def test_sync_project_endpoint_not_found(client):
# Should return 404
assert response.status_code == 404
@pytest.mark.asyncio
async def test_sync_project_endpoint_foreground(test_graph, client, project_url):
"""Test the project sync endpoint with run_in_background=false returns sync report."""
# Call the sync endpoint with run_in_background=false
response = await client.post(f"{project_url}/project/sync?run_in_background=false")
# Verify response
assert response.status_code == 200
data = response.json()
# Check that we get a sync report instead of status message
assert "new" in data
assert "modified" in data
assert "deleted" in data
assert "moves" in data
assert "checksums" in data
assert "skipped_files" in data
assert "total" in data
# Verify these are the right types
assert isinstance(data["new"], list)
assert isinstance(data["modified"], list)
assert isinstance(data["deleted"], list)
assert isinstance(data["moves"], dict)
assert isinstance(data["checksums"], dict)
assert isinstance(data["skipped_files"], list)
assert isinstance(data["total"], int)
@pytest.mark.asyncio
async def test_sync_project_endpoint_foreground_with_force_full(test_graph, client, project_url):
"""Test the project sync endpoint with run_in_background=false and force_full=true."""
# Call the sync endpoint with both parameters
response = await client.post(
f"{project_url}/project/sync?run_in_background=false&force_full=true"
)
# Verify response
assert response.status_code == 200
data = response.json()
# Check that we get a sync report with all expected fields
assert "new" in data
assert "modified" in data
assert "deleted" in data
assert "moves" in data
assert "checksums" in data
assert "skipped_files" in data
assert "total" in data
@pytest.mark.asyncio
async def test_sync_project_endpoint_foreground_with_changes(
test_graph, client, project_config, project_url, tmpdir
):
"""Test foreground sync detects actual file changes."""
# Create a new file in the project directory
import os
from pathlib import Path
test_file = Path(project_config.home) / "new_test_file.md"
test_file.write_text("# New Test File\n\nThis is a test file for sync detection.")
try:
# Call the sync endpoint with run_in_background=false
response = await client.post(f"{project_url}/project/sync?run_in_background=false")
# Verify response
assert response.status_code == 200
data = response.json()
# The sync report should show changes (the new file we created)
assert data["total"] >= 0 # Should have at least detected changes
assert "new" in data
assert "modified" in data
assert "deleted" in data
# At least one of these should have changes
has_changes = len(data["new"]) > 0 or len(data["modified"]) > 0 or len(data["deleted"]) > 0
assert has_changes or data["total"] >= 0 # Either changes detected or empty sync is valid
finally:
# Clean up the test file
if test_file.exists():
os.remove(test_file)
@pytest.mark.asyncio
async def test_remove_default_project_fails(test_config, client, project_service):
"""Test that removing the default project returns an error."""
# Get the current default project
default_project_name = project_service.default_project
# Try to remove the default project
response = await client.delete(f"/projects/{default_project_name}")
# Should return 400 with helpful error message
assert response.status_code == 400
data = response.json()
assert "detail" in data
assert "Cannot delete default project" in data["detail"]
assert default_project_name in data["detail"]
@pytest.mark.asyncio
async def test_remove_default_project_with_alternatives(test_config, client, project_service):
"""Test that error message includes alternative projects when trying to delete default."""
# Get the current default project
default_project_name = project_service.default_project
# Create another project so there are alternatives
test_project_name = "test-alternative-project"
await project_service.add_project(test_project_name, "/tmp/test-alternative")
try:
# Try to remove the default project
response = await client.delete(f"/projects/{default_project_name}")
# Should return 400 with helpful error message including alternatives
assert response.status_code == 400
data = response.json()
assert "detail" in data
assert "Cannot delete default project" in data["detail"]
assert "Set another project as default first" in data["detail"]
assert test_project_name in data["detail"]
finally:
# Clean up
try:
await project_service.remove_project(test_project_name)
except Exception:
pass
@pytest.mark.asyncio
async def test_remove_non_default_project_succeeds(test_config, client, project_service):
"""Test that removing a non-default project succeeds."""
# Create a test project to remove
test_project_name = "test-remove-non-default"
await project_service.add_project(test_project_name, "/tmp/test-remove-non-default")
# Verify it's not the default
assert project_service.default_project != test_project_name
# Remove the project
response = await client.delete(f"/projects/{test_project_name}")
# Should succeed
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify project is removed
removed_project = await project_service.get_project(test_project_name)
assert removed_project is None
@pytest.mark.asyncio
async def test_set_nonexistent_project_as_default_fails(test_config, client, project_service):
"""Test that setting a non-existent project as default returns 404."""
# Try to set a project that doesn't exist as default
response = await client.put("/projects/nonexistent-project/default")
# Should return 404
assert response.status_code == 404
data = response.json()
assert "detail" in data
assert "does not exist" in data["detail"]
@pytest.mark.asyncio
async def test_create_project_idempotent_same_path(test_config, client, project_service):
"""Test that creating a project with same name and same path is idempotent."""
# Create a project with platform-independent path
test_project_name = "test-idempotent"
with tempfile.TemporaryDirectory() as temp_dir:
test_project_path = (Path(temp_dir) / "test-idempotent").as_posix()
response1 = await client.post(
"/projects/projects",
json={"name": test_project_name, "path": test_project_path, "set_default": False},
)
# Should succeed with 201 Created
assert response1.status_code == 201
data1 = response1.json()
assert data1["status"] == "success"
assert data1["new_project"]["name"] == test_project_name
# Try to create the same project again with same name and path
response2 = await client.post(
"/projects/projects",
json={"name": test_project_name, "path": test_project_path, "set_default": False},
)
# Should also succeed (idempotent)
assert response2.status_code == 200
data2 = response2.json()
assert data2["status"] == "success"
assert "already exists" in data2["message"]
assert data2["new_project"]["name"] == test_project_name
# Normalize paths for cross-platform comparison
assert Path(data2["new_project"]["path"]).resolve() == Path(test_project_path).resolve()
# Clean up
try:
await project_service.remove_project(test_project_name)
except Exception:
pass
@pytest.mark.asyncio
async def test_create_project_fails_different_path(test_config, client, project_service):
"""Test that creating a project with same name but different path fails."""
# Create a project
test_project_name = "test-path-conflict"
test_project_path1 = "/tmp/test-path-conflict-1"
response1 = await client.post(
"/projects/projects",
json={"name": test_project_name, "path": test_project_path1, "set_default": False},
)
# Should succeed with 201 Created
assert response1.status_code == 201
# Try to create the same project with different path
test_project_path2 = "/tmp/test-path-conflict-2"
response2 = await client.post(
"/projects/projects",
json={"name": test_project_name, "path": test_project_path2, "set_default": False},
)
# Should fail with 400
assert response2.status_code == 400
data2 = response2.json()
assert "detail" in data2
assert "already exists with different path" in data2["detail"]
assert test_project_path1 in data2["detail"]
assert test_project_path2 in data2["detail"]
# Clean up
try:
await project_service.remove_project(test_project_name)
except Exception:
pass
@pytest.mark.asyncio
async def test_remove_project_with_delete_notes_false(test_config, client, project_service):
"""Test that removing a project with delete_notes=False leaves directory intact."""
# Create a test project with actual directory
test_project_name = "test-remove-keep-files"
with tempfile.TemporaryDirectory() as temp_dir:
test_path = Path(temp_dir) / "test-project"
test_path.mkdir()
test_file = test_path / "test.md"
test_file.write_text("# Test Note")
await project_service.add_project(test_project_name, str(test_path))
# Remove the project without deleting files (default)
response = await client.delete(f"/projects/{test_project_name}")
# Verify response
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify project is removed from config/db
removed_project = await project_service.get_project(test_project_name)
assert removed_project is None
# Verify directory still exists
assert test_path.exists()
assert test_file.exists()
@pytest.mark.asyncio
async def test_remove_project_with_delete_notes_true(test_config, client, project_service):
"""Test that removing a project with delete_notes=True deletes the directory."""
# Create a test project with actual directory
test_project_name = "test-remove-delete-files"
with tempfile.TemporaryDirectory() as temp_dir:
test_path = Path(temp_dir) / "test-project"
test_path.mkdir()
test_file = test_path / "test.md"
test_file.write_text("# Test Note")
await project_service.add_project(test_project_name, str(test_path))
# Remove the project with delete_notes=True
response = await client.delete(f"/projects/{test_project_name}?delete_notes=true")
# Verify response
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify project is removed from config/db
removed_project = await project_service.get_project(test_project_name)
assert removed_project is None
# Verify directory is deleted
assert not test_path.exists()
@pytest.mark.asyncio
async def test_remove_project_delete_notes_nonexistent_directory(
test_config, client, project_service
):
"""Test that removing a project with delete_notes=True handles missing directory gracefully."""
# Create a project pointing to a non-existent path
test_project_name = "test-remove-missing-dir"
test_path = "/tmp/this-directory-does-not-exist-12345"
await project_service.add_project(test_project_name, test_path)
# Remove the project with delete_notes=True (should not fail even if dir doesn't exist)
response = await client.delete(f"/projects/{test_project_name}?delete_notes=true")
# Should succeed
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify project is removed
removed_project = await project_service.get_project(test_project_name)
assert removed_project is None
-463
View File
@@ -1,463 +0,0 @@
"""Tests for bisync_commands module."""
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from basic_memory.cli.commands.cloud.bisync_commands import (
BisyncError,
convert_bmignore_to_rclone_filters,
scan_local_directories,
validate_bisync_directory,
build_bisync_command,
get_bisync_directory,
get_bisync_state_path,
bisync_state_exists,
BISYNC_PROFILES,
)
class TestConvertBmignoreToRcloneFilters:
"""Tests for convert_bmignore_to_rclone_filters()."""
def test_converts_basic_patterns(self, tmp_path):
"""Test conversion of basic gitignore patterns to rclone format."""
bmignore_dir = tmp_path / ".basic-memory"
bmignore_dir.mkdir(exist_ok=True)
bmignore_file = bmignore_dir / ".bmignore"
# Write test patterns
bmignore_file.write_text("# Comment line\nnode_modules\n*.pyc\n.git\n**/*.log\n")
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bmignore_path",
return_value=bmignore_file,
):
convert_bmignore_to_rclone_filters()
# Read the generated rclone filter file
rclone_filter = bmignore_dir / ".bmignore.rclone"
assert rclone_filter.exists()
content = rclone_filter.read_text()
lines = content.strip().split("\n")
# Check comment preserved
assert "# Comment line" in lines
# Check patterns converted correctly
assert "- node_modules/**" in lines # Directory without wildcard
assert "- *.pyc" in lines # Wildcard pattern unchanged
assert "- .git/**" in lines # Directory pattern
assert "- **/*.log" in lines # Wildcard pattern unchanged
def test_handles_empty_bmignore(self, tmp_path):
"""Test handling of empty .bmignore file."""
bmignore_dir = tmp_path / ".basic-memory"
bmignore_dir.mkdir(exist_ok=True)
bmignore_file = bmignore_dir / ".bmignore"
bmignore_file.write_text("")
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bmignore_path",
return_value=bmignore_file,
):
convert_bmignore_to_rclone_filters()
rclone_filter = bmignore_dir / ".bmignore.rclone"
assert rclone_filter.exists()
def test_handles_missing_bmignore(self, tmp_path):
"""Test handling when .bmignore doesn't exist."""
bmignore_dir = tmp_path / ".basic-memory"
bmignore_dir.mkdir(exist_ok=True)
bmignore_file = bmignore_dir / ".bmignore"
# Ensure file doesn't exist
if bmignore_file.exists():
bmignore_file.unlink()
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bmignore_path",
return_value=bmignore_file,
):
with patch("basic_memory.cli.commands.cloud.bisync_commands.create_default_bmignore"):
convert_bmignore_to_rclone_filters()
# Should create minimal filter with .git
rclone_filter = bmignore_dir / ".bmignore.rclone"
assert rclone_filter.exists()
content = rclone_filter.read_text()
assert "- .git/**" in content
class TestScanLocalDirectories:
"""Tests for scan_local_directories()."""
def test_scans_existing_directories(self, tmp_path):
"""Test scanning existing project directories."""
# Use a subdirectory to avoid interference from test fixtures
scan_dir = tmp_path / "scan_test"
scan_dir.mkdir()
# Create test directories
(scan_dir / "project1").mkdir()
(scan_dir / "project2").mkdir()
(scan_dir / "project3").mkdir()
# Create a hidden directory (should be ignored)
(scan_dir / ".hidden").mkdir()
# Create a file (should be ignored)
(scan_dir / "file.txt").write_text("test")
result = scan_local_directories(scan_dir)
assert len(result) == 3
assert "project1" in result
assert "project2" in result
assert "project3" in result
assert ".hidden" not in result
def test_handles_empty_directory(self, tmp_path):
"""Test scanning empty directory."""
scan_dir = tmp_path / "empty_test"
scan_dir.mkdir()
result = scan_local_directories(scan_dir)
assert result == []
def test_handles_nonexistent_directory(self, tmp_path):
"""Test scanning nonexistent directory."""
nonexistent = tmp_path / "does-not-exist"
result = scan_local_directories(nonexistent)
assert result == []
def test_ignores_hidden_directories(self, tmp_path):
"""Test that hidden directories are ignored."""
scan_dir = tmp_path / "hidden_test"
scan_dir.mkdir()
(scan_dir / ".git").mkdir()
(scan_dir / ".cache").mkdir()
(scan_dir / "visible").mkdir()
result = scan_local_directories(scan_dir)
assert len(result) == 1
assert "visible" in result
assert ".git" not in result
assert ".cache" not in result
class TestValidateBisyncDirectory:
"""Tests for validate_bisync_directory()."""
def test_allows_valid_directory(self, tmp_path):
"""Test that valid directory passes validation."""
bisync_dir = tmp_path / "sync"
bisync_dir.mkdir()
# Should not raise
validate_bisync_directory(bisync_dir)
def test_rejects_mount_directory(self, tmp_path):
"""Test that mount directory is rejected."""
mount_dir = Path.home() / "basic-memory-cloud"
with pytest.raises(BisyncError) as exc_info:
validate_bisync_directory(mount_dir)
assert "mount directory" in str(exc_info.value).lower()
@patch("subprocess.run")
def test_rejects_mounted_directory(self, mock_run, tmp_path):
"""Test that currently mounted directory is rejected."""
bisync_dir = tmp_path / "sync"
bisync_dir.mkdir()
# Mock mount command showing this directory is mounted
mock_run.return_value = Mock(
stdout=f"rclone on {bisync_dir} type fuse.rclone",
stderr="",
returncode=0,
)
with pytest.raises(BisyncError) as exc_info:
validate_bisync_directory(bisync_dir)
assert "currently mounted" in str(exc_info.value).lower()
class TestBuildBisyncCommand:
"""Tests for build_bisync_command()."""
def test_builds_basic_command(self, tmp_path):
"""Test building basic bisync command."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["balanced"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
mock_filter.return_value = Path("/test/filter")
cmd = build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
)
assert cmd[0] == "rclone"
assert cmd[1] == "bisync"
assert str(local_path) in cmd
assert f"basic-memory-{tenant_id}:{bucket_name}" in cmd
assert "--create-empty-src-dirs" in cmd
assert "--resilient" in cmd
assert f"--conflict-resolve={profile.conflict_resolve}" in cmd
assert f"--max-delete={profile.max_delete}" in cmd
assert "--progress" in cmd
def test_adds_dry_run_flag(self, tmp_path):
"""Test that dry-run flag is added when requested."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["safe"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
mock_filter.return_value = Path("/test/filter")
cmd = build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
dry_run=True,
)
assert "--dry-run" in cmd
def test_adds_resync_flag(self, tmp_path):
"""Test that resync flag is added when requested."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["balanced"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
mock_filter.return_value = Path("/test/filter")
cmd = build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
resync=True,
)
assert "--resync" in cmd
def test_adds_verbose_flag(self, tmp_path):
"""Test that verbose flag is added when requested."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["fast"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
mock_filter.return_value = Path("/test/filter")
cmd = build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
verbose=True,
)
assert "--verbose" in cmd
assert "--progress" not in cmd # Progress replaced by verbose
def test_creates_state_directory(self, tmp_path):
"""Test that state directory is created."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["balanced"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_state_path"
) as mock_state:
state_path = tmp_path / "state"
mock_filter.return_value = Path("/test/filter")
mock_state.return_value = state_path
build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
)
# State directory should be created
assert state_path.exists()
assert state_path.is_dir()
class TestBisyncStateManagement:
"""Tests for bisync state functions."""
def test_get_bisync_state_path(self):
"""Test state path generation."""
tenant_id = "test-tenant-123"
result = get_bisync_state_path(tenant_id)
expected = Path.home() / ".basic-memory" / "bisync-state" / tenant_id
assert result == expected
def test_bisync_state_exists_true(self, tmp_path):
"""Test checking for existing state."""
state_dir = tmp_path / "state"
state_dir.mkdir()
(state_dir / "test.lst").write_text("test")
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_state_path",
return_value=state_dir,
):
result = bisync_state_exists("test-tenant")
assert result is True
def test_bisync_state_exists_false_no_dir(self, tmp_path):
"""Test checking for nonexistent state directory."""
state_dir = tmp_path / "nonexistent"
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_state_path",
return_value=state_dir,
):
result = bisync_state_exists("test-tenant")
assert result is False
def test_bisync_state_exists_false_empty_dir(self, tmp_path):
"""Test checking for empty state directory."""
state_dir = tmp_path / "state"
state_dir.mkdir()
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_state_path",
return_value=state_dir,
):
result = bisync_state_exists("test-tenant")
assert result is False
class TestGetBisyncDirectory:
"""Tests for get_bisync_directory()."""
def test_returns_default_directory(self):
"""Test that default directory is returned when not configured."""
with patch("basic_memory.cli.commands.cloud.bisync_commands.ConfigManager") as mock_config:
mock_config.return_value.config.bisync_config = {}
result = get_bisync_directory()
expected = Path.home() / "basic-memory-cloud-sync"
assert result == expected
def test_returns_configured_directory(self, tmp_path):
"""Test that configured directory is returned."""
custom_dir = tmp_path / "custom-sync"
with patch("basic_memory.cli.commands.cloud.bisync_commands.ConfigManager") as mock_config:
mock_config.return_value.config.bisync_config = {"sync_dir": str(custom_dir)}
result = get_bisync_directory()
assert result == custom_dir
class TestCloudProjectAutoRegistration:
"""Tests for project auto-registration logic."""
@pytest.mark.asyncio
async def test_extracts_directory_names_from_cloud_paths(self):
"""Test extraction of directory names from cloud project paths."""
from basic_memory.cli.commands.cloud.cloud_utils import fetch_cloud_projects
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
mock_response = Mock()
mock_response.json.return_value = {
"projects": [
{"name": "Main Project", "path": "/app/data/basic-memory"},
{"name": "Work", "path": "/app/data/work-notes"},
{"name": "Personal", "path": "/app/data/personal"},
]
}
mock_request.return_value = mock_response
result = await fetch_cloud_projects()
# Extract directory names as the code does
cloud_dir_names = set()
for p in result.projects:
path = p.path
if path.startswith("/app/data/"):
path = path[len("/app/data/") :]
dir_name = Path(path).name
cloud_dir_names.add(dir_name)
assert cloud_dir_names == {"basic-memory", "work-notes", "personal"}
@pytest.mark.asyncio
async def test_create_cloud_project_generates_permalink(self):
"""Test that create_cloud_project generates correct permalink."""
from basic_memory.cli.commands.cloud.cloud_utils import create_cloud_project
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
mock_permalink.return_value = "my-new-project"
mock_response = Mock()
mock_response.json.return_value = {
"message": "Project 'My New Project' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {"name": "My New Project", "path": "my-new-project"},
}
mock_request.return_value = mock_response
await create_cloud_project("My New Project")
# Verify permalink was generated
mock_permalink.assert_called_once_with("My New Project")
# Verify request was made with correct data
call_args = mock_request.call_args
json_data = call_args.kwargs["json_data"]
assert json_data["name"] == "My New Project"
assert json_data["path"] == "my-new-project"
assert json_data["set_default"] is False
-326
View File
@@ -1,326 +0,0 @@
"""Tests for cloud_utils module."""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from basic_memory.cli.commands.cloud.cloud_utils import (
CloudUtilsError,
create_cloud_project,
fetch_cloud_projects,
project_exists,
sync_project,
)
class TestFetchCloudProjects:
"""Tests for fetch_cloud_projects()."""
@pytest.mark.asyncio
async def test_fetches_projects_successfully(self):
"""Test successful fetch of cloud projects."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
# Setup config
mock_config.return_value.config.cloud_host = "https://example.com"
# Mock API response
mock_response = Mock()
mock_response.json.return_value = {
"projects": [
{"name": "Project 1", "path": "/app/data/project-1"},
{"name": "Project 2", "path": "/app/data/project-2"},
]
}
mock_request.return_value = mock_response
result = await fetch_cloud_projects()
# Verify result
assert len(result.projects) == 2
assert result.projects[0].name == "Project 1"
assert result.projects[1].name == "Project 2"
# Verify API was called correctly
mock_request.assert_called_once_with(
method="GET", url="https://example.com/proxy/projects/projects"
)
@pytest.mark.asyncio
async def test_strips_trailing_slash_from_host(self):
"""Test that trailing slash is stripped from cloud_host."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
# Setup config with trailing slash
mock_config.return_value.config.cloud_host = "https://example.com/"
mock_response = Mock()
mock_response.json.return_value = {"projects": []}
mock_request.return_value = mock_response
await fetch_cloud_projects()
# Verify trailing slash was removed
call_args = mock_request.call_args
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
@pytest.mark.asyncio
async def test_raises_error_on_api_failure(self):
"""Test that CloudUtilsError is raised on API failure."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
mock_config.return_value.config.cloud_host = "https://example.com"
mock_request.side_effect = Exception("API Error")
with pytest.raises(CloudUtilsError) as exc_info:
await fetch_cloud_projects()
assert "Failed to fetch cloud projects" in str(exc_info.value)
@pytest.mark.asyncio
async def test_handles_empty_project_list(self):
"""Test handling of empty project list."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
mock_config.return_value.config.cloud_host = "https://example.com"
mock_response = Mock()
mock_response.json.return_value = {"projects": []}
mock_request.return_value = mock_response
result = await fetch_cloud_projects()
assert len(result.projects) == 0
class TestCreateCloudProject:
"""Tests for create_cloud_project()."""
@pytest.mark.asyncio
async def test_creates_project_successfully(self):
"""Test successful project creation."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
# Setup mocks
mock_config.return_value.config.cloud_host = "https://example.com"
mock_permalink.return_value = "my-project"
mock_response = Mock()
mock_response.json.return_value = {
"message": "Project 'My Project' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {"name": "My Project", "path": "my-project"},
}
mock_request.return_value = mock_response
result = await create_cloud_project("My Project")
# Verify result
assert result.message == "Project 'My Project' added successfully"
assert result.status == "success"
assert result.default is False
# Verify permalink was generated
mock_permalink.assert_called_once_with("My Project")
# Verify API request
call_args = mock_request.call_args
assert call_args[1]["method"] == "POST"
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
assert call_args[1]["headers"]["Content-Type"] == "application/json"
json_data = call_args[1]["json_data"]
assert json_data["name"] == "My Project"
assert json_data["path"] == "my-project"
assert json_data["set_default"] is False
@pytest.mark.asyncio
async def test_generates_permalink_from_name(self):
"""Test that permalink is generated from project name."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
mock_config.return_value.config.cloud_host = "https://example.com"
mock_permalink.return_value = "test-project-123"
mock_response = Mock()
mock_response.json.return_value = {
"message": "Project 'Test Project 123' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {"name": "Test Project 123", "path": "test-project-123"},
}
mock_request.return_value = mock_response
await create_cloud_project("Test Project 123")
# Verify generate_permalink was called with project name
mock_permalink.assert_called_once_with("Test Project 123")
@pytest.mark.asyncio
async def test_raises_error_on_api_failure(self):
"""Test that CloudUtilsError is raised on API failure."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
mock_config.return_value.config.cloud_host = "https://example.com"
mock_permalink.return_value = "project"
mock_request.side_effect = Exception("API Error")
with pytest.raises(CloudUtilsError) as exc_info:
await create_cloud_project("Test Project")
assert "Failed to create cloud project 'Test Project'" in str(exc_info.value)
@pytest.mark.asyncio
async def test_strips_trailing_slash_from_host(self):
"""Test that trailing slash is stripped from cloud_host."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
mock_config.return_value.config.cloud_host = "https://example.com/"
mock_permalink.return_value = "project"
mock_response = Mock()
mock_response.json.return_value = {
"message": "Project 'Project' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {"name": "Project", "path": "project"},
}
mock_request.return_value = mock_response
await create_cloud_project("Project")
# Verify trailing slash was removed
call_args = mock_request.call_args
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
class TestSyncProject:
"""Tests for sync_project()."""
@pytest.mark.asyncio
async def test_syncs_project_successfully(self):
"""Test successful project sync."""
# Patch at the point where it's imported (inside the function)
with patch(
"basic_memory.cli.commands.command_utils.run_sync", new_callable=AsyncMock
) as mock_sync:
await sync_project("test-project")
# Verify run_sync was called with project name
mock_sync.assert_called_once_with(project="test-project")
@pytest.mark.asyncio
async def test_raises_error_on_sync_failure(self):
"""Test that CloudUtilsError is raised on sync failure."""
# Patch at the point where it's imported (inside the function)
with patch(
"basic_memory.cli.commands.command_utils.run_sync", new_callable=AsyncMock
) as mock_sync:
mock_sync.side_effect = Exception("Sync failed")
with pytest.raises(CloudUtilsError) as exc_info:
await sync_project("test-project")
assert "Failed to sync project 'test-project'" in str(exc_info.value)
class TestProjectExists:
"""Tests for project_exists()."""
@pytest.mark.asyncio
async def test_returns_true_when_project_exists(self):
"""Test that True is returned when project exists."""
from basic_memory.schemas.cloud import CloudProject, CloudProjectList
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
# Create actual CloudProject objects
projects = CloudProjectList(
projects=[
CloudProject(name="project-1", path="/app/data/project-1"),
CloudProject(name="test-project", path="/app/data/test-project"),
CloudProject(name="project-2", path="/app/data/project-2"),
]
)
mock_fetch.return_value = projects
result = await project_exists("test-project")
assert result is True
@pytest.mark.asyncio
async def test_returns_false_when_project_not_found(self):
"""Test that False is returned when project doesn't exist."""
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
# Mock project list without matching project
mock_projects = Mock()
mock_projects.projects = [
Mock(name="project-1"),
Mock(name="project-2"),
]
mock_fetch.return_value = mock_projects
result = await project_exists("nonexistent-project")
assert result is False
@pytest.mark.asyncio
async def test_returns_false_on_api_error(self):
"""Test that False is returned on API error."""
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
mock_fetch.side_effect = Exception("API Error")
result = await project_exists("test-project")
# Should return False instead of raising exception
assert result is False
@pytest.mark.asyncio
async def test_handles_empty_project_list(self):
"""Test handling of empty project list."""
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
mock_projects = Mock()
mock_projects.projects = []
mock_fetch.return_value = mock_projects
result = await project_exists("any-project")
assert result is False
@pytest.mark.asyncio
async def test_case_sensitive_matching(self):
"""Test that project name matching is case-sensitive."""
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
mock_projects = Mock()
mock_projects.projects = [Mock(name="Test-Project")]
mock_fetch.return_value = mock_projects
# Different case should not match
result = await project_exists("test-project")
assert result is False
@@ -149,3 +149,55 @@ def test_import_conversation_with_attachments(tmp_path):
assert "**Attachment: test.txt**" in content
assert "```" in content
assert "Test file content" in content
def test_import_conversation_with_none_text_values(tmp_path):
"""Test importing conversation with None text values in content array (issue #236)."""
# Create conversation with None text values
conversation = {
"uuid": "test-uuid",
"name": "Test With None Text",
"created_at": "2025-01-05T20:55:32.499880+00:00",
"updated_at": "2025-01-05T20:56:39.477600+00:00",
"chat_messages": [
{
"uuid": "msg-1",
"text": None,
"sender": "human",
"created_at": "2025-01-05T20:55:32.499880+00:00",
"content": [
{"type": "text", "text": "Valid text here"},
{"type": "text", "text": None}, # This caused the TypeError
{"type": "text", "text": "More valid text"},
],
},
{
"uuid": "msg-2",
"text": None,
"sender": "assistant",
"created_at": "2025-01-05T20:55:40.123456+00:00",
"content": [
{"type": "text", "text": None}, # All None case
{"type": "text", "text": None},
],
},
],
}
json_file = tmp_path / "with_none_text.json"
with open(json_file, "w", encoding="utf-8") as f:
json.dump([conversation], f)
config = get_project_config()
config.home = tmp_path
# Run import - should not fail with TypeError
result = runner.invoke(app, ["import", "claude", "conversations", str(json_file)])
assert result.exit_code == 0
# Check that valid text is preserved and None values are filtered out
conv_path = tmp_path / "conversations/20250105-Test_With_None_Text.md"
assert conv_path.exists()
content = conv_path.read_text(encoding="utf-8")
assert "Valid text here" in content
assert "More valid text" in content
@@ -0,0 +1,149 @@
"""Tests for bm project add with --local-path flag."""
import json
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from typer.testing import CliRunner
from basic_memory.cli.app import app
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_config(tmp_path, monkeypatch):
"""Create a mock config in cloud mode using environment variables."""
config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.json"
config_data = {
"env": "dev",
"projects": {},
"default_project": "main",
"cloud_mode": True,
"cloud_projects": {},
}
config_file.write_text(json.dumps(config_data, indent=2))
# Set HOME to tmp_path so ConfigManager uses our test config
monkeypatch.setenv("HOME", str(tmp_path))
yield config_file
@pytest.fixture
def mock_api_client():
"""Mock the API client for project add."""
with patch("basic_memory.cli.commands.project.get_client"):
# Mock call_post to return a proper response
mock_response = AsyncMock()
mock_response.json = lambda: {
"message": "Project 'test-project' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {
"name": "test-project",
"path": "/test-project",
"is_default": False,
},
}
with patch(
"basic_memory.cli.commands.project.call_post", return_value=mock_response
) as mock_post:
yield mock_post
def test_project_add_with_local_path_saves_to_config(
runner, mock_config, mock_api_client, tmp_path
):
"""Test that bm project add --local-path saves sync path to config."""
local_sync_dir = tmp_path / "sync" / "test-project"
result = runner.invoke(
app,
[
"project",
"add",
"test-project",
"--local-path",
str(local_sync_dir),
],
)
assert result.exit_code == 0, f"Exit code: {result.exit_code}, Stdout: {result.stdout}"
assert "Project 'test-project' added successfully" in result.stdout
assert "Local sync path configured" in result.stdout
# Check path is present (may be line-wrapped in output)
assert "test-project" in result.stdout
assert "sync" in result.stdout
# Verify config was updated
config_data = json.loads(mock_config.read_text())
assert "test-project" in config_data["cloud_projects"]
# Use as_posix() for cross-platform compatibility (Windows uses backslashes)
assert config_data["cloud_projects"]["test-project"]["local_path"] == local_sync_dir.as_posix()
assert config_data["cloud_projects"]["test-project"]["last_sync"] is None
assert config_data["cloud_projects"]["test-project"]["bisync_initialized"] is False
# Verify local directory was created
assert local_sync_dir.exists()
assert local_sync_dir.is_dir()
def test_project_add_without_local_path_no_config_entry(runner, mock_config, mock_api_client):
"""Test that bm project add without --local-path doesn't save to config."""
result = runner.invoke(
app,
["project", "add", "test-project"],
)
assert result.exit_code == 0
assert "Project 'test-project' added successfully" in result.stdout
assert "Local sync path configured" not in result.stdout
# Verify config was NOT updated with cloud_projects entry
config_data = json.loads(mock_config.read_text())
assert "test-project" not in config_data.get("cloud_projects", {})
def test_project_add_local_path_expands_tilde(runner, mock_config, mock_api_client):
"""Test that --local-path ~/path expands to absolute path."""
result = runner.invoke(
app,
["project", "add", "test-project", "--local-path", "~/test-sync"],
)
assert result.exit_code == 0
# Verify config has expanded path
config_data = json.loads(mock_config.read_text())
local_path = config_data["cloud_projects"]["test-project"]["local_path"]
# Path should be absolute (starts with / on Unix or drive letter on Windows)
assert Path(local_path).is_absolute()
assert "~" not in local_path
assert local_path.endswith("/test-sync")
def test_project_add_local_path_creates_nested_directories(
runner, mock_config, mock_api_client, tmp_path
):
"""Test that --local-path creates nested directories."""
nested_path = tmp_path / "a" / "b" / "c" / "test-project"
result = runner.invoke(
app,
["project", "add", "test-project", "--local-path", str(nested_path)],
)
assert result.exit_code == 0
assert nested_path.exists()
assert nested_path.is_dir()
+185 -1
View File
@@ -20,7 +20,7 @@ class TestGetFilesToUpload:
(tmp_path / "subdir" / "file3.py").write_text("content3")
# Call with real ignore utils (no mocking)
result = _get_files_to_upload(tmp_path)
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=True)
# Should find all 3 files
assert len(result) == 3
@@ -326,3 +326,187 @@ class TestUploadPath:
mock_put.assert_called_once()
call_args = mock_put.call_args
assert call_args[0][1] == "/webdav/my-project/subdir/file.txt"
@pytest.mark.asyncio
async def test_skips_archive_files(self, tmp_path, capsys):
"""Test that archive files are skipped during upload."""
# Create test files including archives
(tmp_path / "notes.md").write_text("content")
(tmp_path / "backup.zip").write_text("fake zip")
(tmp_path / "data.tar.gz").write_text("fake tar")
mock_client = AsyncMock()
mock_response = Mock()
mock_response.raise_for_status = Mock()
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
with patch(
"basic_memory.cli.commands.cloud.upload._get_files_to_upload"
) as mock_get_files:
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
mock_get_client.return_value.__aenter__.return_value = mock_client
mock_get_client.return_value.__aexit__.return_value = None
mock_put.return_value = mock_response
# Mock file listing with all files
mock_get_files.return_value = [
(tmp_path / "notes.md", "notes.md"),
(tmp_path / "backup.zip", "backup.zip"),
(tmp_path / "data.tar.gz", "data.tar.gz"),
]
mock_file = AsyncMock()
mock_file.read.return_value = b"content"
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
result = await upload_path(tmp_path, "test-project")
# Should succeed
assert result is True
# Should only upload the .md file (not the archives)
assert mock_put.call_count == 1
call_args = mock_put.call_args
assert "notes.md" in call_args[0][1]
# Check output mentions skipping
captured = capsys.readouterr()
assert "Skipping archive file" in captured.out
assert "backup.zip" in captured.out
assert "Skipped 2 archive file(s)" in captured.out
def test_no_gitignore_skips_gitignore_patterns(self, tmp_path):
"""Test that --no-gitignore flag skips .gitignore patterns."""
# Create test files
(tmp_path / "keep.txt").write_text("keep")
(tmp_path / "secret.bak").write_text("secret") # Use .bak instead of .pyc
# Create .gitignore file that ignores .bak files
gitignore_file = tmp_path / ".gitignore"
gitignore_file.write_text("*.bak\n")
# With use_gitignore=False, should include .bak files
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=False)
# Extract relative paths
relative_paths = [rel_path for _, rel_path in result]
# Both files should be included when gitignore is disabled
assert "keep.txt" in relative_paths
assert "secret.bak" in relative_paths
def test_no_gitignore_still_respects_bmignore(self, tmp_path):
"""Test that --no-gitignore still respects .bmignore patterns."""
# Create test files
(tmp_path / "keep.txt").write_text("keep")
(tmp_path / ".hidden").write_text(
"hidden"
) # Should be ignored by .bmignore default pattern
# Create .gitignore that would allow .hidden
gitignore_file = tmp_path / ".gitignore"
gitignore_file.write_text("# Allow all\n")
# With use_gitignore=False, should still filter hidden files via .bmignore
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=False)
# Extract relative paths
relative_paths = [rel_path for _, rel_path in result]
# keep.txt should be included, .hidden should be filtered by .bmignore
assert "keep.txt" in relative_paths
assert ".hidden" not in relative_paths
def test_verbose_shows_filtering_info(self, tmp_path, capsys):
"""Test that verbose mode shows filtering information."""
# Create test files
(tmp_path / "keep.txt").write_text("keep")
(tmp_path / "ignore.pyc").write_text("ignore")
# Create .gitignore
gitignore_file = tmp_path / ".gitignore"
gitignore_file.write_text("*.pyc\n")
# Run with verbose=True
_get_files_to_upload(tmp_path, verbose=True, use_gitignore=True)
# Capture output
captured = capsys.readouterr()
# Should show scanning information
assert "Scanning directory:" in captured.out
assert "Using .bmignore: Yes" in captured.out
assert "Using .gitignore:" in captured.out
assert "Ignore patterns loaded:" in captured.out
# Should show file status
assert "[INCLUDE]" in captured.out or "[IGNORED]" in captured.out
# Should show summary
assert "Summary:" in captured.out
assert "Files to upload:" in captured.out
assert "Files ignored:" in captured.out
def test_wildcard_gitignore_filters_all_files(self, tmp_path):
"""Test that a wildcard * in .gitignore filters all files."""
# Create test files
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.md").write_text("content2")
# Create .gitignore with wildcard
gitignore_file = tmp_path / ".gitignore"
gitignore_file.write_text("*\n")
# Should filter all files
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=True)
assert len(result) == 0
# With use_gitignore=False, should include files
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=False)
assert len(result) == 2
@pytest.mark.asyncio
async def test_dry_run_shows_files_without_uploading(self, tmp_path, capsys):
"""Test that --dry-run shows what would be uploaded without uploading."""
# Create test files
(tmp_path / "file1.txt").write_text("content1")
(tmp_path / "file2.txt").write_text("content2")
# Don't mock anything - we want to verify no actual upload happens
result = await upload_path(tmp_path, "test-project", dry_run=True)
# Should return success
assert result is True
# Check output shows dry run info
captured = capsys.readouterr()
assert "Found 2 file(s) to upload" in captured.out
assert "Files that would be uploaded:" in captured.out
assert "file1.txt" in captured.out
assert "file2.txt" in captured.out
assert "Total:" in captured.out
@pytest.mark.asyncio
async def test_dry_run_with_verbose(self, tmp_path, capsys):
"""Test that --dry-run works with --verbose."""
# Create test files
(tmp_path / "keep.txt").write_text("keep")
(tmp_path / "ignore.pyc").write_text("ignore")
# Create .gitignore
gitignore_file = tmp_path / ".gitignore"
gitignore_file.write_text("*.pyc\n")
result = await upload_path(tmp_path, "test-project", verbose=True, dry_run=True)
# Should return success
assert result is True
# Check output shows both verbose and dry run info
captured = capsys.readouterr()
assert "Scanning directory:" in captured.out
assert "[INCLUDE] keep.txt" in captured.out
assert "[IGNORED] ignore.pyc" in captured.out
assert "Files that would be uploaded:" in captured.out
assert "keep.txt" in captured.out
+2
View File
@@ -250,6 +250,7 @@ async def sync_service(
app_config: BasicMemoryConfig,
entity_service: EntityService,
entity_parser: EntityParser,
project_repository: ProjectRepository,
entity_repository: EntityRepository,
relation_repository: RelationRepository,
search_service: SearchService,
@@ -259,6 +260,7 @@ async def sync_service(
return SyncService(
app_config=app_config,
entity_service=entity_service,
project_repository=project_repository,
entity_repository=entity_repository,
relation_repository=relation_repository,
entity_parser=entity_parser,
@@ -0,0 +1,236 @@
"""Test that YAML date parsing doesn't break frontmatter processing.
This test reproduces GitHub issue #236 from basic-memory-cloud where date fields
in YAML frontmatter are automatically parsed as datetime.date objects by PyYAML,
but later code expects strings and calls .strip() on them, causing AttributeError.
"""
import pytest
from basic_memory.markdown.entity_parser import EntityParser
@pytest.fixture
def test_file_with_date(tmp_path):
"""Create a test file with date fields in frontmatter."""
test_file = tmp_path / "test_note.md"
content = """---
title: Test Note
date: 2025-10-24
created: 2025-10-24
tags:
- python
- testing
---
# Test Content
This file has date fields in frontmatter that PyYAML will parse as datetime.date objects.
"""
test_file.write_text(content)
return test_file
@pytest.fixture
def test_file_with_date_in_tags(tmp_path):
"""Create a test file with a date value in tags (edge case)."""
test_file = tmp_path / "test_note_date_tags.md"
content = """---
title: Test Note with Date Tags
tags: 2025-10-24
---
# Test Content
This file has a date value as tags, which will be parsed as datetime.date.
"""
test_file.write_text(content)
return test_file
@pytest.fixture
def test_file_with_dates_in_tag_list(tmp_path):
"""Create a test file with dates in a tag list (edge case)."""
test_file = tmp_path / "test_note_dates_in_list.md"
content = """---
title: Test Note with Dates in Tags List
tags:
- valid-tag
- 2025-10-24
- another-tag
---
# Test Content
This file has date values mixed into tags list.
"""
test_file.write_text(content)
return test_file
@pytest.mark.asyncio
async def test_parse_file_with_date_fields(test_file_with_date, tmp_path):
"""Test that files with date fields in frontmatter can be parsed without errors."""
parser = EntityParser(tmp_path)
# This should not raise AttributeError about .strip()
entity_markdown = await parser.parse_file(test_file_with_date)
# Verify basic parsing worked
assert entity_markdown.frontmatter.title == "Test Note"
# Date fields should be converted to ISO format strings
date_field = entity_markdown.frontmatter.metadata.get("date")
assert date_field is not None
assert isinstance(date_field, str), "Date should be converted to string"
assert date_field == "2025-10-24", "Date should be in ISO format"
created_field = entity_markdown.frontmatter.metadata.get("created")
assert created_field is not None
assert isinstance(created_field, str), "Created date should be converted to string"
assert created_field == "2025-10-24", "Created date should be in ISO format"
@pytest.mark.asyncio
async def test_parse_file_with_date_as_tags(test_file_with_date_in_tags, tmp_path):
"""Test that date values in tags field don't cause errors."""
parser = EntityParser(tmp_path)
# This should not raise AttributeError - date should be converted to string
entity_markdown = await parser.parse_file(test_file_with_date_in_tags)
assert entity_markdown.frontmatter.title == "Test Note with Date Tags"
# The date should be converted to ISO format string before parse_tags processes it
tags = entity_markdown.frontmatter.tags
assert tags is not None
assert isinstance(tags, list)
# The date value should be converted to string
assert "2025-10-24" in tags
@pytest.mark.asyncio
async def test_parse_file_with_dates_in_tag_list(test_file_with_dates_in_tag_list, tmp_path):
"""Test that date values in a tags list don't cause errors."""
parser = EntityParser(tmp_path)
# This should not raise AttributeError - dates should be converted to strings
entity_markdown = await parser.parse_file(test_file_with_dates_in_tag_list)
assert entity_markdown.frontmatter.title == "Test Note with Dates in Tags List"
# Tags should be parsed
tags = entity_markdown.frontmatter.tags
assert tags is not None
assert isinstance(tags, list)
# Should have 3 tags (2 valid + 1 date converted to ISO string)
assert len(tags) == 3
assert "valid-tag" in tags
assert "another-tag" in tags
# Date should be converted to ISO format string
assert "2025-10-24" in tags
@pytest.mark.asyncio
async def test_parse_file_with_various_yaml_types(tmp_path):
"""Test that various YAML types in frontmatter don't cause errors.
This reproduces the broader issue from GitHub #236 where ANY non-string
YAML type (dates, lists, numbers, booleans) can cause AttributeError
when code expects strings and calls .strip().
"""
test_file = tmp_path / "test_yaml_types.md"
content = """---
title: Test YAML Types
date: 2025-10-24
priority: 1
completed: true
tags:
- python
- testing
metadata:
author: Test User
version: 1.0
---
# Test Content
This file has various YAML types that need to be normalized.
"""
test_file.write_text(content)
parser = EntityParser(tmp_path)
entity_markdown = await parser.parse_file(test_file)
# All values should be accessible without AttributeError
assert entity_markdown.frontmatter.title == "Test YAML Types"
# Date should be converted to ISO string
date_field = entity_markdown.frontmatter.metadata.get("date")
assert isinstance(date_field, str)
assert date_field == "2025-10-24"
# Number should be converted to string
priority = entity_markdown.frontmatter.metadata.get("priority")
assert isinstance(priority, str)
assert priority == "1"
# Boolean should be converted to string
completed = entity_markdown.frontmatter.metadata.get("completed")
assert isinstance(completed, str)
assert completed == "True" # Python's str(True) always returns "True"
# List should be preserved as list, but items should be strings
tags = entity_markdown.frontmatter.tags
assert isinstance(tags, list)
assert all(isinstance(tag, str) for tag in tags)
assert "python" in tags
assert "testing" in tags
# Dict should be preserved as dict, but nested values should be strings
metadata = entity_markdown.frontmatter.metadata.get("metadata")
assert isinstance(metadata, dict)
assert isinstance(metadata.get("author"), str)
assert metadata.get("author") == "Test User"
assert isinstance(metadata.get("version"), str)
assert metadata.get("version") in ("1.0", "1")
@pytest.mark.asyncio
async def test_parse_file_with_datetime_objects(tmp_path):
"""Test that datetime objects (not just date objects) are properly normalized.
This tests the edge case where frontmatter might contain datetime values
with time components (as parsed by PyYAML), ensuring they're converted to ISO format strings.
"""
test_file = tmp_path / "test_datetime.md"
# YAML datetime strings that PyYAML will parse as datetime objects
# Format: YYYY-MM-DD HH:MM:SS or YYYY-MM-DDTHH:MM:SS
content = """---
title: Test Datetime
created_at: 2025-10-24 14:30:00
updated_at: 2025-10-24T00:00:00
---
# Test Content
This file has datetime values in frontmatter that PyYAML will parse as datetime objects.
"""
test_file.write_text(content)
parser = EntityParser(tmp_path)
entity_markdown = await parser.parse_file(test_file)
# Verify datetime objects are converted to ISO format strings
created_at = entity_markdown.frontmatter.metadata.get("created_at")
assert isinstance(created_at, str), "Datetime should be converted to string"
# PyYAML parses "2025-10-24 14:30:00" as datetime, which we normalize to ISO
assert "2025-10-24" in created_at and "14:30:00" in created_at, (
f"Datetime with time should be normalized to ISO format, got: {created_at}"
)
updated_at = entity_markdown.frontmatter.metadata.get("updated_at")
assert isinstance(updated_at, str), "Datetime should be converted to string"
# PyYAML parses "2025-10-24T00:00:00" as datetime, which we normalize to ISO
assert "2025-10-24" in updated_at and "00:00:00" in updated_at, (
f"Datetime at midnight should be normalized to ISO format, got: {updated_at}"
)
@@ -0,0 +1,293 @@
"""Tests for entity parser error handling (issues #184 and #185)."""
import pytest
from textwrap import dedent
from basic_memory.markdown.entity_parser import EntityParser
@pytest.mark.asyncio
async def test_parse_file_with_malformed_yaml_frontmatter(tmp_path):
"""Test that files with malformed YAML frontmatter are parsed gracefully (issue #185).
This reproduces the production error where block sequence entries cause YAML parsing to fail.
The parser should handle the error gracefully and treat the file as plain markdown.
"""
# Create a file with malformed YAML frontmatter
test_file = tmp_path / "malformed.md"
content = dedent(
"""
---
title: Group Chat Texts
tags:
- family # Line 5, column 7 - this syntax can fail in certain YAML contexts
- messages
type: note
---
# Group Chat Texts
Content here
"""
).strip()
test_file.write_text(content)
# Parse the file - should not raise YAMLError
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should successfully parse, treating as plain markdown if YAML fails
assert result is not None
# If YAML parsing succeeded, verify expected values
# If it failed, it should have defaults
assert result.frontmatter.title is not None
assert result.frontmatter.type is not None
@pytest.mark.asyncio
async def test_parse_file_with_completely_invalid_yaml(tmp_path):
"""Test that files with completely invalid YAML are handled gracefully (issue #185).
This tests the extreme case where YAML parsing completely fails.
"""
# Create a file with completely broken YAML
test_file = tmp_path / "broken_yaml.md"
content = dedent(
"""
---
title: Invalid YAML
this is: [not, valid, yaml
missing: closing bracket
---
# Content
This file has broken YAML frontmatter.
"""
).strip()
test_file.write_text(content)
# Parse the file - should not raise exception
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should successfully parse with defaults
assert result is not None
assert result.frontmatter.title == "broken_yaml" # Default from filename
assert result.frontmatter.type == "note" # Default type
# Content should include the whole file since frontmatter parsing failed
assert "# Content" in result.content
@pytest.mark.asyncio
async def test_parse_file_without_entity_type(tmp_path):
"""Test that files without entity_type get a default value (issue #184).
This reproduces the NOT NULL constraint error where entity_type was missing.
"""
# Create a file without entity_type in frontmatter
test_file = tmp_path / "no_type.md"
content = dedent(
"""
---
title: The Invisible Weight of Mental Habits
---
# The Invisible Weight of Mental Habits
An article about mental habits.
"""
).strip()
test_file.write_text(content)
# Parse the file
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should have default entity_type
assert result is not None
assert result.frontmatter.type == "note" # Default type applied
assert result.frontmatter.title == "The Invisible Weight of Mental Habits"
@pytest.mark.asyncio
async def test_parse_file_with_empty_frontmatter(tmp_path):
"""Test that files with empty frontmatter get defaults (issue #184)."""
# Create a file with empty frontmatter
test_file = tmp_path / "empty_frontmatter.md"
content = dedent(
"""
---
---
# Content
This file has empty frontmatter.
"""
).strip()
test_file.write_text(content)
# Parse the file
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should have defaults
assert result is not None
assert result.frontmatter.type == "note" # Default type
assert result.frontmatter.title == "empty_frontmatter" # Default from filename
@pytest.mark.asyncio
async def test_parse_file_without_frontmatter(tmp_path):
"""Test that files without any frontmatter get defaults (issue #184)."""
# Create a file with no frontmatter at all
test_file = tmp_path / "no_frontmatter.md"
content = dedent(
"""
# Just Content
This file has no frontmatter at all.
"""
).strip()
test_file.write_text(content)
# Parse the file
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should have defaults
assert result is not None
assert result.frontmatter.type == "note" # Default type
assert result.frontmatter.title == "no_frontmatter" # Default from filename
@pytest.mark.asyncio
async def test_parse_file_with_null_entity_type(tmp_path):
"""Test that files with explicit null entity_type get default (issue #184)."""
# Create a file with null/None entity_type
test_file = tmp_path / "null_type.md"
content = dedent(
"""
---
title: Test File
type: null
---
# Content
"""
).strip()
test_file.write_text(content)
# Parse the file
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should have default type even when explicitly set to null
assert result is not None
assert result.frontmatter.type == "note" # Default type applied
assert result.frontmatter.title == "Test File"
@pytest.mark.asyncio
async def test_parse_file_with_null_title(tmp_path):
"""Test that files with explicit null title get default from filename (issue #387)."""
# Create a file with null title
test_file = tmp_path / "null_title.md"
content = dedent(
"""
---
title: null
type: note
---
# Content
"""
).strip()
test_file.write_text(content)
# Parse the file
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should have default title from filename even when explicitly set to null
assert result is not None
assert result.frontmatter.title == "null_title" # Default from filename
assert result.frontmatter.type == "note"
@pytest.mark.asyncio
async def test_parse_file_with_empty_title(tmp_path):
"""Test that files with empty title get default from filename (issue #387)."""
# Create a file with empty title
test_file = tmp_path / "empty_title.md"
content = dedent(
"""
---
title:
type: note
---
# Content
"""
).strip()
test_file.write_text(content)
# Parse the file
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should have default title from filename when title is empty
assert result is not None
assert result.frontmatter.title == "empty_title" # Default from filename
assert result.frontmatter.type == "note"
@pytest.mark.asyncio
async def test_parse_file_with_string_none_title(tmp_path):
"""Test that files with string 'None' title get default from filename (issue #387)."""
# Create a file with string "None" as title (common in templates)
test_file = tmp_path / "template_file.md"
content = dedent(
"""
---
title: "None"
type: note
---
# Content
"""
).strip()
test_file.write_text(content)
# Parse the file
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should have default title from filename when title is string "None"
assert result is not None
assert result.frontmatter.title == "template_file" # Default from filename
assert result.frontmatter.type == "note"
@pytest.mark.asyncio
async def test_parse_valid_file_still_works(tmp_path):
"""Test that valid files with proper frontmatter still parse correctly."""
# Create a valid file
test_file = tmp_path / "valid.md"
content = dedent(
"""
---
title: Valid File
type: knowledge
tags:
- test
- valid
---
# Valid File
This is a properly formatted file.
"""
).strip()
test_file.write_text(content)
# Parse the file
parser = EntityParser(tmp_path)
result = await parser.parse_file(test_file)
# Should parse correctly with all values
assert result is not None
assert result.frontmatter.title == "Valid File"
assert result.frontmatter.type == "knowledge"
assert result.frontmatter.tags == ["test", "valid"]
@@ -24,6 +24,22 @@ from basic_memory.config import ProjectConfig
from basic_memory.services import EntityService
async def force_full_scan(sync_service: SyncService) -> None:
"""Force next sync to do a full scan by clearing watermark (for testing moves/deletions)."""
if sync_service.entity_repository.project_id is not None:
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
if project:
await sync_service.project_repository.update(
project.id,
{
"last_scan_timestamp": None,
"last_file_count": None,
},
)
@pytest.mark.asyncio
async def test_permalink_collision_should_not_overwrite_different_file(app, test_project):
"""Test that creating notes with different titles doesn't overwrite existing files.
@@ -299,6 +315,10 @@ async def test_sync_permalink_collision_file_overwrite_bug(
node_b_file = edge_cases_dir / "Node B.md"
node_b_file.write_text(node_b_content)
# Force full scan to detect the new file
# (file just created may not be newer than watermark due to timing precision)
await force_full_scan(sync_service)
# Sync to create Node B
await sync_service.sync(project_dir)
@@ -323,6 +343,10 @@ async def test_sync_permalink_collision_file_overwrite_bug(
node_c_file = edge_cases_dir / "Node C.md"
node_c_file.write_text(node_c_content)
# Force full scan to detect the new file
# (file just created may not be newer than watermark due to timing precision)
await force_full_scan(sync_service)
# Sync to create Node C - THIS IS WHERE THE BUG OCCURS
await sync_service.sync(project_dir)
+1 -1
View File
@@ -402,7 +402,7 @@ class TestReadNoteSecurityValidation:
Additional content with various formatting.
""").strip(),
tags=["security", "test", "full-feature"],
entity_type="guide",
note_type="guide",
)
# Test reading by permalink
-170
View File
@@ -1,170 +0,0 @@
"""Tests for sync_status MCP tool."""
import pytest
from unittest.mock import MagicMock, patch
from basic_memory.mcp.tools.sync_status import sync_status
from basic_memory.services.sync_status_service import (
SyncStatus,
ProjectSyncStatus,
SyncStatusTracker,
)
@pytest.mark.asyncio
async def test_sync_status_completed():
"""Test sync_status when all operations are completed."""
# Mock sync status tracker with ready status
mock_tracker = MagicMock(spec=SyncStatusTracker)
mock_tracker.is_ready = True
mock_tracker.get_summary.return_value = "✅ All projects synced successfully"
mock_tracker.get_all_projects.return_value = {}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "System Ready**: ✅ Yes" in result
assert "All sync operations completed" in result
assert "File indexing is complete" in result
assert "knowledge base is ready for use" in result
@pytest.mark.asyncio
async def test_sync_status_in_progress():
"""Test sync_status when sync is in progress."""
# Mock sync status tracker with in progress status
mock_tracker = MagicMock(spec=SyncStatusTracker)
mock_tracker.is_ready = False
mock_tracker.get_summary.return_value = "🔄 Syncing 2 projects (5/10 files, 50%)"
# Mock active projects
project1 = ProjectSyncStatus(
project_name="project1",
status=SyncStatus.SYNCING,
message="Processing new files",
files_total=5,
files_processed=3,
)
project2 = ProjectSyncStatus(
project_name="project2",
status=SyncStatus.SCANNING,
message="Scanning files",
files_total=5,
files_processed=2,
)
mock_tracker.get_all_projects.return_value = {"project1": project1, "project2": project2}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "System Ready**: 🔄 Processing" in result
assert "File synchronization in progress" in result
assert "project1**: Processing new files (3/5, 60%)" in result
assert "project2**: Scanning files (2/5, 40%)" in result
assert "Scanning and indexing markdown files" in result
assert "Use this tool again to check progress" in result
@pytest.mark.asyncio
async def test_sync_status_failed():
"""Test sync_status when sync has failed."""
# Mock sync status tracker with failed project
mock_tracker = MagicMock(spec=SyncStatusTracker)
mock_tracker.is_ready = False
mock_tracker.get_summary.return_value = "❌ Sync failed for: project1"
# Mock failed project
failed_project = ProjectSyncStatus(
project_name="project1",
status=SyncStatus.FAILED,
message="Sync failed",
error="Permission denied",
)
mock_tracker.get_all_projects.return_value = {"project1": failed_project}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "System Ready**: 🔄 Processing" in result
assert "Some projects failed to sync" in result
assert "project1**: Permission denied" in result
assert "Check the logs for detailed error information" in result
assert "Try restarting the MCP server" in result
@pytest.mark.asyncio
async def test_sync_status_idle():
"""Test sync_status when system is idle."""
# Mock sync status tracker with idle status
mock_tracker = MagicMock(spec=SyncStatusTracker)
mock_tracker.is_ready = True
mock_tracker.get_summary.return_value = "✅ System ready"
mock_tracker.get_all_projects.return_value = {}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "System Ready**: ✅ Yes" in result
assert "All sync operations completed" in result
@pytest.mark.asyncio
async def test_sync_status_with_project():
"""Test sync_status with specific project context."""
# Mock sync status tracker
mock_tracker = MagicMock(spec=SyncStatusTracker)
mock_tracker.is_ready = True
mock_tracker.get_summary.return_value = "✅ All projects synced successfully"
# Mock specific project status
project_status = ProjectSyncStatus(
project_name="test-project",
status=SyncStatus.COMPLETED,
message="Sync completed",
files_total=10,
files_processed=10,
)
mock_tracker.get_project_status.return_value = project_status
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status.fn(project="test-project")
# The function should use the original logic for project-specific queries
# But since we changed the implementation, let's just verify it doesn't crash
assert "Basic Memory Sync Status" in result
@pytest.mark.asyncio
async def test_sync_status_pending():
"""Test sync_status when no projects are active."""
# Mock sync status tracker with no active projects
mock_tracker = MagicMock(spec=SyncStatusTracker)
mock_tracker.is_ready = False
mock_tracker.get_summary.return_value = "✅ System ready"
mock_tracker.get_all_projects.return_value = {}
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await sync_status.fn()
assert "Basic Memory Sync Status" in result
assert "Sync operations pending" in result
assert "usually resolves automatically" in result
@pytest.mark.asyncio
async def test_sync_status_error_handling():
"""Test sync_status handles errors gracefully."""
# Mock sync status tracker that raises an exception
with patch("basic_memory.services.sync_status_service.sync_status_tracker") as mock_tracker:
mock_tracker.is_ready = True
mock_tracker.get_summary.side_effect = Exception("Test error")
result = await sync_status.fn()
assert "Unable to check sync status**: Test error" in result
+1 -82
View File
@@ -1,6 +1,6 @@
"""Tests for MCP tool utilities."""
from unittest.mock import AsyncMock, patch, MagicMock
from unittest.mock import AsyncMock
import pytest
from httpx import AsyncClient, HTTPStatusError
@@ -12,8 +12,6 @@ from basic_memory.mcp.tools.utils import (
call_put,
call_delete,
get_error_message,
check_migration_status,
wait_for_migration_or_return_status,
)
@@ -184,82 +182,3 @@ async def test_call_post_with_json(mock_response):
mock_post.assert_called_once()
call_kwargs = mock_post.call_args[1]
assert call_kwargs["json"] == json_data
class TestMigrationStatus:
"""Test migration status checking functions."""
def test_check_migration_status_ready(self):
"""Test check_migration_status when system is ready."""
mock_tracker = MagicMock()
mock_tracker.is_ready = True
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = check_migration_status()
assert result is None
def test_check_migration_status_not_ready(self):
"""Test check_migration_status when sync is in progress."""
mock_tracker = MagicMock()
mock_tracker.is_ready = False
mock_tracker.get_summary.return_value = "Sync in progress..."
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = check_migration_status()
assert result == "Sync in progress..."
mock_tracker.get_summary.assert_called_once()
def test_check_migration_status_exception(self):
"""Test check_migration_status with import/other exception."""
# Mock the import itself to raise an exception
with patch("builtins.__import__", side_effect=ImportError("Module not found")):
result = check_migration_status()
assert result is None
@pytest.mark.asyncio
async def test_wait_for_migration_ready(self):
"""Test wait_for_migration when system is already ready."""
mock_tracker = MagicMock()
mock_tracker.is_ready = True
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
result = await wait_for_migration_or_return_status()
assert result is None
@pytest.mark.asyncio
async def test_wait_for_migration_becomes_ready(self):
"""Test wait_for_migration when system becomes ready during wait."""
mock_tracker = MagicMock()
mock_tracker.is_ready = False
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
# Mock asyncio.sleep to make tracker ready after first check
async def mock_sleep(delay):
mock_tracker.is_ready = True
with patch("asyncio.sleep", side_effect=mock_sleep):
result = await wait_for_migration_or_return_status(timeout=1.0)
assert result is None
@pytest.mark.asyncio
async def test_wait_for_migration_timeout(self):
"""Test wait_for_migration when timeout occurs."""
mock_tracker = MagicMock()
mock_tracker.is_ready = False
mock_tracker.get_summary.return_value = "Still syncing..."
with patch("basic_memory.services.sync_status_service.sync_status_tracker", mock_tracker):
with patch("asyncio.sleep", new_callable=AsyncMock):
result = await wait_for_migration_or_return_status(timeout=0.1)
assert result == "Still syncing..."
mock_tracker.get_summary.assert_called_once()
@pytest.mark.asyncio
async def test_wait_for_migration_exception(self):
"""Test wait_for_migration with exception during checking."""
with patch(
"basic_memory.services.sync_status_service.sync_status_tracker",
side_effect=Exception("Test error"),
):
result = await wait_for_migration_or_return_status()
assert result is None
+10 -10
View File
@@ -538,7 +538,7 @@ async def test_write_note_with_custom_entity_type(app, test_project):
folder="guides",
content="# Guide Content\nThis is a guide",
tags=["guide", "documentation"],
entity_type="guide",
note_type="guide",
)
assert result
@@ -574,14 +574,14 @@ async def test_write_note_with_custom_entity_type(app, test_project):
@pytest.mark.asyncio
async def test_write_note_with_report_entity_type(app, test_project):
"""Test creating a note with entity_type="report"."""
"""Test creating a note with note_type="report"."""
result = await write_note.fn(
project=test_project.name,
title="Monthly Report",
folder="reports",
content="# Monthly Report\nThis is a monthly report",
tags=["report", "monthly"],
entity_type="report",
note_type="report",
)
assert result
@@ -599,13 +599,13 @@ async def test_write_note_with_report_entity_type(app, test_project):
@pytest.mark.asyncio
async def test_write_note_with_config_entity_type(app, test_project):
"""Test creating a note with entity_type="config"."""
"""Test creating a note with note_type="config"."""
result = await write_note.fn(
project=test_project.name,
title="System Config",
folder="config",
content="# System Configuration\nThis is a config file",
entity_type="config",
note_type="config",
)
assert result
@@ -659,21 +659,21 @@ async def test_write_note_update_existing_with_different_entity_type(app, test_p
folder="test",
content="# Initial Content\nThis starts as a note",
tags=["test"],
entity_type="note",
note_type="note",
)
assert result1
assert "# Created note" in result1
assert f"project: {test_project.name}" in result1
# Update the same note with a different entity_type
# Update the same note with a different note_type
result2 = await write_note.fn(
project=test_project.name,
title="Changeable Type",
folder="test",
content="# Updated Content\nThis is now a guide",
tags=["guide"],
entity_type="guide",
note_type="guide",
)
assert result2
@@ -976,7 +976,7 @@ class TestWriteNoteSecurityValidation:
folder="../../../etc/malicious",
content="# Malicious Content\nThis should be blocked by security validation.",
tags=["malicious", "test"],
entity_type="guide",
note_type="guide",
)
assert isinstance(result, str)
@@ -1026,7 +1026,7 @@ class TestWriteNoteSecurityValidation:
Additional content with various formatting.
""").strip(),
tags=["security", "test", "full-feature"],
entity_type="guide",
note_type="guide",
)
# Should succeed normally
+166
View File
@@ -644,3 +644,169 @@ async def test_find_by_directory_prefix_basic_fields_only(
assert entity.entity_type == "test"
assert entity.content_type == "text/markdown"
assert entity.updated_at is not None
@pytest.mark.asyncio
async def test_get_all_file_paths(entity_repository: EntityRepository, session_maker):
"""Test getting all file paths for deletion detection during sync."""
# Create test entities with various file paths
async with db.scoped_session(session_maker) as session:
entities = [
Entity(
project_id=entity_repository.project_id,
title="File 1",
entity_type="test",
permalink="docs/file1",
file_path="docs/file1.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
project_id=entity_repository.project_id,
title="File 2",
entity_type="test",
permalink="specs/file2",
file_path="specs/file2.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
Entity(
project_id=entity_repository.project_id,
title="File 3",
entity_type="test",
permalink="notes/file3",
file_path="notes/file3.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
),
]
session.add_all(entities)
await session.flush()
# Get all file paths
file_paths = await entity_repository.get_all_file_paths()
# Verify results
assert isinstance(file_paths, list)
assert len(file_paths) == 3
assert set(file_paths) == {"docs/file1.md", "specs/file2.md", "notes/file3.md"}
@pytest.mark.asyncio
async def test_get_all_file_paths_empty_db(entity_repository: EntityRepository):
"""Test getting all file paths when database is empty."""
file_paths = await entity_repository.get_all_file_paths()
assert file_paths == []
@pytest.mark.asyncio
async def test_get_all_file_paths_performance(entity_repository: EntityRepository, session_maker):
"""Test that get_all_file_paths doesn't load entities or relationships.
This method is optimized for deletion detection during streaming sync.
It should only query file_path strings, not full entity objects.
"""
# Create test entity with observations and relations
async with db.scoped_session(session_maker) as session:
# Create entities
entity1 = Entity(
project_id=entity_repository.project_id,
title="Entity 1",
entity_type="test",
permalink="test/entity1",
file_path="test/entity1.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
entity2 = Entity(
project_id=entity_repository.project_id,
title="Entity 2",
entity_type="test",
permalink="test/entity2",
file_path="test/entity2.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
session.add_all([entity1, entity2])
await session.flush()
# Add observations to entity1
observation = Observation(
entity_id=entity1.id,
content="Test observation",
category="note",
)
session.add(observation)
# Add relation between entities
relation = Relation(
from_id=entity1.id,
to_id=entity2.id,
to_name=entity2.title,
relation_type="relates_to",
)
session.add(relation)
await session.flush()
# Get all file paths - should be fast and not load relationships
file_paths = await entity_repository.get_all_file_paths()
# Verify results - just file paths, no entities or relationships loaded
assert len(file_paths) == 2
assert set(file_paths) == {"test/entity1.md", "test/entity2.md"}
# Result should be list of strings, not entity objects
for path in file_paths:
assert isinstance(path, str)
@pytest.mark.asyncio
async def test_get_all_file_paths_project_isolation(
entity_repository: EntityRepository, session_maker
):
"""Test that get_all_file_paths only returns paths from the current project."""
# Create entities in the repository's project
async with db.scoped_session(session_maker) as session:
entity1 = Entity(
project_id=entity_repository.project_id,
title="Project 1 File",
entity_type="test",
permalink="test/file1",
file_path="test/file1.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
session.add(entity1)
await session.flush()
# Create a second project
project2 = Project(name="other-project", path="/tmp/other")
session.add(project2)
await session.flush()
# Create entity in different project
entity2 = Entity(
project_id=project2.id,
title="Project 2 File",
entity_type="test",
permalink="test/file2",
file_path="test/file2.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
session.add(entity2)
await session.flush()
# Get all file paths for project 1
file_paths = await entity_repository.get_all_file_paths()
# Should only include files from project 1
assert len(file_paths) == 1
assert file_paths == ["test/file1.md"]
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from basic_memory.models.knowledge import Entity
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.services.exceptions import SyncFatalError
@pytest.mark.asyncio
@@ -436,3 +437,32 @@ async def test_upsert_entity_permalink_conflict_within_project_only(session_make
assert result1.project_id == project1.id
assert result2.project_id == project2.id
assert result3.project_id == project1.id
@pytest.mark.asyncio
async def test_upsert_entity_with_invalid_project_id(entity_repository: EntityRepository):
"""Test that upserting with non-existent project_id raises clear error.
This tests the fix for issue #188 where sync fails with FOREIGN KEY constraint
violations when a project is deleted during sync operations.
"""
# Create entity with non-existent project_id
entity = Entity(
title="Test Entity",
entity_type="note",
file_path="test.md",
permalink="test",
project_id=99999, # This project doesn't exist
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Should raise SyncFatalError with clear message about missing project
with pytest.raises(SyncFatalError) as exc_info:
await entity_repository.upsert_entity(entity)
error_msg = str(exc_info.value)
assert "project_id=99999 does not exist" in error_msg
assert "project may have been deleted" in error_msg.lower()
assert "sync will be terminated" in error_msg.lower()
@@ -0,0 +1,132 @@
"""Tests for issue #187 - UNIQUE constraint violation on file_path during sync."""
import pytest
from datetime import datetime, timezone
from basic_memory.models.knowledge import Entity, Observation
from basic_memory.repository.entity_repository import EntityRepository
@pytest.mark.asyncio
async def test_upsert_entity_with_observations_conflict(entity_repository: EntityRepository):
"""Test upserting an entity that already exists with observations.
This reproduces issue #187 where sync fails with UNIQUE constraint violations
when trying to update entities that already exist with observations.
"""
# Create initial entity with observations
entity1 = Entity(
project_id=entity_repository.project_id,
title="Original Title",
entity_type="note",
permalink="debugging/backup-system/coderabbit-feedback-resolution",
file_path="debugging/backup-system/CodeRabbit Feedback Resolution - Backup System Issues.md",
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Add observations to the entity
obs1 = Observation(
content="This is a test observation",
category="testing",
tags=["test"],
)
entity1.observations.append(obs1)
result1 = await entity_repository.upsert_entity(entity1)
original_id = result1.id
# Verify entity was created with observations
assert result1.id is not None
assert len(result1.observations) == 1
# Now try to upsert the same file_path with different content/observations
# This simulates a file being modified and re-synced
entity2 = Entity(
project_id=entity_repository.project_id,
title="Updated Title",
entity_type="note",
permalink="debugging/backup-system/coderabbit-feedback-resolution", # Same permalink
file_path="debugging/backup-system/CodeRabbit Feedback Resolution - Backup System Issues.md", # Same file_path
content_type="text/markdown",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Add different observations
obs2 = Observation(
content="This is an updated observation",
category="updated",
tags=["updated"],
)
obs3 = Observation(
content="This is a second observation",
category="second",
tags=["second"],
)
entity2.observations.extend([obs2, obs3])
# This should UPDATE the existing entity, not fail with IntegrityError
result2 = await entity_repository.upsert_entity(entity2)
# Should update existing entity (same ID)
assert result2.id == original_id
assert result2.title == "Updated Title"
assert result2.file_path == entity1.file_path
assert result2.permalink == entity1.permalink
# Observations should be updated
assert len(result2.observations) == 2
assert result2.observations[0].content == "This is an updated observation"
assert result2.observations[1].content == "This is a second observation"
@pytest.mark.asyncio
async def test_upsert_entity_repeated_sync_same_file(entity_repository: EntityRepository):
"""Test that syncing the same file multiple times doesn't cause IntegrityError.
This tests the specific scenario from issue #187 where files are being
synced repeatedly and hitting UNIQUE constraint violations.
"""
file_path = "processes/Complete Process for Uploading New Training Videos.md"
permalink = "processes/complete-process-for-uploading-new-training-videos"
# Create initial entity
entity1 = Entity(
project_id=entity_repository.project_id,
title="Complete Process for Uploading New Training Videos",
entity_type="note",
permalink=permalink,
file_path=file_path,
content_type="text/markdown",
checksum="abc123",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
result1 = await entity_repository.upsert_entity(entity1)
first_id = result1.id
# Simulate multiple sync attempts (like the infinite retry loop in the issue)
for i in range(5):
entity_new = Entity(
project_id=entity_repository.project_id,
title="Complete Process for Uploading New Training Videos",
entity_type="note",
permalink=permalink,
file_path=file_path,
content_type="text/markdown",
checksum=f"def{456 + i}", # Different checksum each time
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
# Each upsert should succeed and update the existing entity
result = await entity_repository.upsert_entity(entity_new)
# Should always return the same entity (updated)
assert result.id == first_id
assert result.checksum == entity_new.checksum
assert result.file_path == file_path
assert result.permalink == permalink
+1 -1
View File
@@ -348,7 +348,7 @@ class TestTimeframeParsing:
result_1d = parse_timeframe("1d")
expected_1d = now - timedelta(days=1)
diff = abs((result_1d - expected_1d).total_seconds())
assert diff < 60 # Within 1 minute tolerance
assert diff < 3600 # Within 1 hour tolerance (accounts for DST transitions)
assert result_1d.tzinfo is not None
# Test yesterday - should be yesterday at same time
+49
View File
@@ -1275,6 +1275,55 @@ async def test_edit_entity_replace_section_with_subsections(
assert "Other content" in file_content
@pytest.mark.asyncio
async def test_edit_entity_replace_section_strips_duplicate_header(
entity_service: EntityService, file_service: FileService
):
"""Test that replace_section strips duplicate header from content (issue #390)."""
# Create test entity with a section
content = dedent("""
# Main Title
## Testing
Original content
## Another Section
Other content
""").strip()
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
entity_type="note",
content=content,
)
)
# Replace section with content that includes the duplicate header
# (This is what LLMs sometimes do)
updated = await entity_service.edit_entity(
identifier=entity.permalink,
operation="replace_section",
content="## Testing\nNew content for testing section",
section="## Testing",
)
# Verify that we don't have duplicate headers
file_path = file_service.get_entity_path(updated)
file_content, _ = await file_service.read_file(file_path)
# Count occurrences of "## Testing" - should only be 1
testing_header_count = file_content.count("## Testing")
assert testing_header_count == 1, (
f"Expected 1 '## Testing' header, found {testing_header_count}"
)
assert "New content for testing section" in file_content
assert "Original content" not in file_content
assert "## Another Section" in file_content # Other sections preserved
# Move entity tests
@pytest.mark.asyncio
async def test_move_entity_success(
+157
View File
@@ -1198,3 +1198,160 @@ async def test_add_project_nested_validation_with_project_root(
# Clean up
if parent_project_name in project_service.projects:
await project_service.remove_project(parent_project_name)
@pytest.mark.asyncio
async def test_synchronize_projects_removes_db_only_projects(project_service: ProjectService):
"""Test that synchronize_projects removes projects that exist in DB but not in config.
This is a regression test for issue #193 where deleted projects would be re-added
to config during synchronization, causing them to reappear after deletion.
Config is the source of truth - if a project is deleted from config, it should be
removed from the database during synchronization.
"""
test_project_name = f"test-db-only-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
test_project_path = str(test_root / "test-db-only")
# Make sure the test directory exists
os.makedirs(test_project_path, exist_ok=True)
try:
# Add project to database only (not to config) - simulating orphaned DB entry
project_data = {
"name": test_project_name,
"path": test_project_path,
"permalink": test_project_name.lower().replace(" ", "-"),
"is_active": True,
}
await project_service.repository.create(project_data)
# Verify it exists in DB but not in config
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is not None
assert test_project_name not in project_service.projects
# Call synchronize_projects - this should remove the orphaned DB entry
# because config is the source of truth
await project_service.synchronize_projects()
# Verify project was removed from database
db_project_after = await project_service.repository.get_by_name(test_project_name)
assert db_project_after is None, (
"Project should be removed from DB when not in config (config is source of truth)"
)
# Verify it's still not in config
assert test_project_name not in project_service.projects
finally:
# Clean up if needed
db_project = await project_service.repository.get_by_name(test_project_name)
if db_project:
await project_service.repository.delete(db_project.id)
@pytest.mark.asyncio
async def test_remove_project_with_delete_notes_false(project_service: ProjectService):
"""Test that remove_project with delete_notes=False keeps directory intact."""
test_project_name = f"test-remove-keep-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
test_project_path = test_root / "test-project"
test_project_path.mkdir()
test_file = test_project_path / "test.md"
test_file.write_text("# Test Note")
try:
# Add project
await project_service.add_project(test_project_name, str(test_project_path))
# Verify project exists
assert test_project_name in project_service.projects
assert test_project_path.exists()
assert test_file.exists()
# Remove project without deleting notes (default behavior)
await project_service.remove_project(test_project_name, delete_notes=False)
# Verify project is removed from config/db
assert test_project_name not in project_service.projects
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is None
# Verify directory and files still exist
assert test_project_path.exists()
assert test_file.exists()
finally:
# Cleanup happens automatically with temp_dir context manager
pass
@pytest.mark.asyncio
async def test_remove_project_with_delete_notes_true(project_service: ProjectService):
"""Test that remove_project with delete_notes=True deletes directory."""
test_project_name = f"test-remove-delete-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
test_project_path = test_root / "test-project"
test_project_path.mkdir()
test_file = test_project_path / "test.md"
test_file.write_text("# Test Note")
try:
# Add project
await project_service.add_project(test_project_name, str(test_project_path))
# Verify project exists
assert test_project_name in project_service.projects
assert test_project_path.exists()
assert test_file.exists()
# Remove project with delete_notes=True
await project_service.remove_project(test_project_name, delete_notes=True)
# Verify project is removed from config/db
assert test_project_name not in project_service.projects
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is None
# Verify directory and files are deleted
assert not test_project_path.exists()
finally:
# Cleanup happens automatically with temp_dir context manager
pass
@pytest.mark.asyncio
async def test_remove_project_delete_notes_missing_directory(project_service: ProjectService):
"""Test that remove_project with delete_notes=True handles missing directory gracefully."""
test_project_name = f"test-remove-missing-{os.urandom(4).hex()}"
test_project_path = f"/tmp/nonexistent-directory-{os.urandom(8).hex()}"
try:
# Add project pointing to non-existent path
await project_service.add_project(test_project_name, test_project_path)
# Verify project exists in config/db
assert test_project_name in project_service.projects
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is not None
# Remove project with delete_notes=True (should not fail even if dir doesn't exist)
await project_service.remove_project(test_project_name, delete_notes=True)
# Verify project is removed from config/db
assert test_project_name not in project_service.projects
db_project = await project_service.repository.get_by_name(test_project_name)
assert db_project is None
finally:
# Ensure cleanup
if test_project_name in project_service.projects:
try:
project_service.config_manager.remove_project(test_project_name)
except Exception:
pass
-262
View File
@@ -1,262 +0,0 @@
"""Test sync status service functionality."""
import pytest
from basic_memory.services.sync_status_service import SyncStatusTracker, SyncStatus
@pytest.fixture
def sync_tracker():
"""Create a fresh sync status tracker for testing."""
return SyncStatusTracker()
def test_sync_tracker_initial_state(sync_tracker):
"""Test initial state of sync tracker."""
assert sync_tracker.is_ready
assert not sync_tracker.is_syncing
assert sync_tracker.global_status == SyncStatus.IDLE
assert sync_tracker.get_summary() == "✅ System ready"
# Test project-specific ready check for unknown project
assert sync_tracker.is_project_ready("unknown-project")
def test_start_project_sync(sync_tracker):
"""Test starting project sync."""
sync_tracker.start_project_sync("test-project", files_total=10)
assert not sync_tracker.is_ready
assert sync_tracker.is_syncing
assert sync_tracker.global_status == SyncStatus.SYNCING
project_status = sync_tracker.get_project_status("test-project")
assert project_status is not None
assert project_status.status == SyncStatus.SCANNING
assert project_status.message == "Scanning files"
assert project_status.files_total == 10
def test_update_project_progress(sync_tracker):
"""Test updating project progress."""
sync_tracker.start_project_sync("test-project") # Use default files_total=0
sync_tracker.update_project_progress(
"test-project", SyncStatus.SYNCING, "Processing files", files_processed=5, files_total=10
)
project_status = sync_tracker.get_project_status("test-project")
assert project_status.status == SyncStatus.SYNCING
assert project_status.message == "Processing files"
assert project_status.files_processed == 5
assert project_status.files_total == 10
assert sync_tracker.global_status == SyncStatus.SYNCING
def test_complete_project_sync(sync_tracker):
"""Test completing project sync."""
sync_tracker.start_project_sync("test-project")
sync_tracker.complete_project_sync("test-project")
assert sync_tracker.is_ready
assert not sync_tracker.is_syncing
assert sync_tracker.global_status == SyncStatus.COMPLETED
project_status = sync_tracker.get_project_status("test-project")
assert project_status.status == SyncStatus.COMPLETED
assert project_status.message == "Sync completed"
def test_fail_project_sync(sync_tracker):
"""Test failing project sync."""
sync_tracker.start_project_sync("test-project")
sync_tracker.fail_project_sync("test-project", "Connection error")
assert not sync_tracker.is_ready
assert not sync_tracker.is_syncing
assert sync_tracker.global_status == SyncStatus.FAILED
project_status = sync_tracker.get_project_status("test-project")
assert project_status.status == SyncStatus.FAILED
assert project_status.error == "Connection error"
def test_start_project_watch(sync_tracker):
"""Test starting project watch mode."""
sync_tracker.start_project_watch("test-project")
assert sync_tracker.is_ready
assert not sync_tracker.is_syncing
assert sync_tracker.global_status == SyncStatus.COMPLETED
project_status = sync_tracker.get_project_status("test-project")
assert project_status.status == SyncStatus.WATCHING
assert project_status.message == "Watching for changes"
def test_multiple_projects_status(sync_tracker):
"""Test status with multiple projects."""
sync_tracker.start_project_sync("project1")
sync_tracker.start_project_sync("project2")
# Both scanning - should be syncing
assert sync_tracker.global_status == SyncStatus.SYNCING
assert sync_tracker.is_syncing
# Complete one project
sync_tracker.complete_project_sync("project1")
assert sync_tracker.global_status == SyncStatus.SYNCING # Still syncing
# Complete second project
sync_tracker.complete_project_sync("project2")
assert sync_tracker.global_status == SyncStatus.COMPLETED
assert sync_tracker.is_ready
def test_mixed_project_statuses(sync_tracker):
"""Test mixed project statuses."""
sync_tracker.start_project_sync("project1")
sync_tracker.start_project_sync("project2")
# Fail one project
sync_tracker.fail_project_sync("project1", "Error")
# Complete other project
sync_tracker.complete_project_sync("project2")
# Should show failed status
assert sync_tracker.global_status == SyncStatus.FAILED
assert not sync_tracker.is_ready
def test_get_summary_with_progress(sync_tracker):
"""Test summary with progress information."""
sync_tracker.start_project_sync("project1")
sync_tracker.update_project_progress(
"project1", SyncStatus.SYNCING, "Processing", files_processed=25, files_total=100
)
summary = sync_tracker.get_summary()
assert "🔄 Syncing 1 projects" in summary
assert "(25/100 files, 25%)" in summary
def test_get_all_projects(sync_tracker):
"""Test getting all project statuses."""
sync_tracker.start_project_sync("project1")
sync_tracker.start_project_sync("project2")
all_projects = sync_tracker.get_all_projects()
assert len(all_projects) == 2
assert "project1" in all_projects
assert "project2" in all_projects
assert all_projects["project1"].status == SyncStatus.SCANNING
assert all_projects["project2"].status == SyncStatus.SCANNING
def test_clear_completed(sync_tracker):
"""Test clearing completed project statuses."""
sync_tracker.start_project_sync("project1")
sync_tracker.start_project_sync("project2")
sync_tracker.complete_project_sync("project1")
sync_tracker.fail_project_sync("project2", "Error")
# Should have 2 projects before clearing
assert len(sync_tracker.get_all_projects()) == 2
sync_tracker.clear_completed()
# Should only have the failed project after clearing
remaining = sync_tracker.get_all_projects()
assert len(remaining) == 1
assert "project2" in remaining
assert remaining["project2"].status == SyncStatus.FAILED
def test_summary_messages(sync_tracker):
"""Test various summary messages."""
# Initial state
assert sync_tracker.get_summary() == "✅ System ready"
# All completed
sync_tracker.start_project_sync("project1")
sync_tracker.complete_project_sync("project1")
assert sync_tracker.get_summary() == "✅ All projects synced successfully"
# Failed projects
sync_tracker.fail_project_sync("project1", "Test error")
assert "❌ Sync failed for: project1" in sync_tracker.get_summary()
def test_global_status_edge_cases(sync_tracker):
"""Test edge cases for global status calculation."""
# Test mixed statuses (some completed, some watching) - should be completed
sync_tracker.start_project_sync("project1")
sync_tracker.start_project_sync("project2")
sync_tracker.complete_project_sync("project1")
sync_tracker.start_project_watch("project2")
assert sync_tracker.global_status == SyncStatus.COMPLETED
# Test fallback case - create a scenario that doesn't match specific conditions
sync_tracker.start_project_sync("project3")
sync_tracker.update_project_progress("project3", SyncStatus.IDLE, "Idle")
# This should trigger the "else" clause in _update_global_status
assert sync_tracker.global_status == SyncStatus.SYNCING
def test_summary_without_file_counts(sync_tracker):
"""Test summary when projects don't have file counts."""
sync_tracker.start_project_sync("project1") # files_total defaults to 0
sync_tracker.start_project_sync("project2") # files_total defaults to 0
# Don't set file counts - should use the fallback message
summary = sync_tracker.get_summary()
assert "🔄 Syncing 2 projects" in summary
assert "files" not in summary # Should not show file progress
def test_is_project_ready_functionality(sync_tracker):
"""Test project-specific ready checks."""
# Unknown project should be ready
assert sync_tracker.is_project_ready("unknown-project")
# Project in different states
sync_tracker.start_project_sync("scanning-project")
assert not sync_tracker.is_project_ready("scanning-project") # SCANNING = not ready
sync_tracker.update_project_progress("scanning-project", SyncStatus.SYNCING, "Processing")
assert not sync_tracker.is_project_ready("scanning-project") # SYNCING = not ready
sync_tracker.fail_project_sync("scanning-project", "Test error")
assert not sync_tracker.is_project_ready("scanning-project") # FAILED = not ready
sync_tracker.complete_project_sync("scanning-project")
assert sync_tracker.is_project_ready("scanning-project") # COMPLETED = ready
# Test watching project
sync_tracker.start_project_watch("watching-project")
assert sync_tracker.is_project_ready("watching-project") # WATCHING = ready
def test_project_isolation_scenario(sync_tracker):
"""Test the specific bug scenario: project isolation with mixed sync states."""
# Set up the bug scenario: one failed project, one healthy project
sync_tracker.start_project_sync("main")
sync_tracker.fail_project_sync(
"main", "UNIQUE constraint failed: entity.file_path, entity.project_id"
)
sync_tracker.start_project_sync("basic-memory-testing-20250626-1009")
sync_tracker.complete_project_sync("basic-memory-testing-20250626-1009")
sync_tracker.start_project_watch("basic-memory-testing-20250626-1009")
# Global status should be failed due to "main" project
assert sync_tracker.global_status == SyncStatus.FAILED
assert not sync_tracker.is_ready
# But the healthy project should be ready for operations
assert sync_tracker.is_project_ready("basic-memory-testing-20250626-1009")
assert not sync_tracker.is_project_ready("main")
# This demonstrates the fix: project-specific checks allow isolation
+669 -14
View File
@@ -23,6 +23,32 @@ async def create_test_file(path: Path, content: str = "test content") -> None:
path.write_text(content)
async def touch_file(path: Path) -> None:
"""Touch a file to update its mtime (for watermark testing)."""
import time
# Read and rewrite to update mtime
content = path.read_text()
time.sleep(0.5) # Ensure mtime changes and is newer than watermark (500ms)
path.write_text(content)
async def force_full_scan(sync_service: SyncService) -> None:
"""Force next sync to do a full scan by clearing watermark (for testing moves/deletions)."""
if sync_service.entity_repository.project_id is not None:
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
if project:
await sync_service.project_repository.update(
project.id,
{
"last_scan_timestamp": None,
"last_file_count": None,
},
)
@pytest.mark.asyncio
async def test_forward_reference_resolution(
sync_service: SyncService,
@@ -62,7 +88,12 @@ type: knowledge
# Target Doc
Target content
"""
await create_test_file(project_dir / "target_doc.md", target_content)
target_file = project_dir / "target_doc.md"
await create_test_file(target_file, target_content)
# Force full scan to ensure the new file is detected
# Incremental scans have timing precision issues with watermarks on some filesystems
await force_full_scan(sync_service)
# Sync again - should resolve the reference
await sync_service.sync(project_config.home)
@@ -475,9 +506,10 @@ async def test_sync_empty_directories(sync_service: SyncService, project_config:
await sync_service.sync(project_config.home)
# Should not raise exceptions for empty dirs
assert (project_config.home).exists()
assert project_config.home.exists()
@pytest.mark.skip("flaky on Windows due to filesystem timing precision")
@pytest.mark.asyncio
async def test_sync_file_modified_during_sync(
sync_service: SyncService, project_config: ProjectConfig
@@ -537,8 +569,7 @@ async def test_permalink_formatting(
}
# Create test files
for filename, _ in test_files.items():
content: str = """
content: str = """
---
type: knowledge
created: 2024-01-01
@@ -548,10 +579,11 @@ modified: 2024-01-01
Testing permalink generation.
"""
for filename, _ in test_files.items():
await create_test_file(project_config.home / filename, content)
# Run sync
await sync_service.sync(project_config.home)
# Run sync once after all files are created
await sync_service.sync(project_config.home)
# Verify permalinks
entities = await entity_service.repository.find_all()
@@ -567,7 +599,6 @@ Testing permalink generation.
async def test_handle_entity_deletion(
test_graph,
sync_service: SyncService,
project_config: ProjectConfig,
entity_repository: EntityRepository,
search_service: SearchService,
):
@@ -644,6 +675,91 @@ Testing file timestamps
assert abs(entity_updated_epoch - file_stats.st_mtime) < tolerance # Allow tolerance difference
@pytest.mark.asyncio
async def test_sync_updates_timestamps_on_file_modification(
sync_service: SyncService,
project_config: ProjectConfig,
entity_service: EntityService,
):
"""Test that sync updates entity timestamps when files are modified.
This test specifically validates that when an existing file is modified and re-synced,
the updated_at timestamp in the database reflects the file's actual modification time,
not the database operation time. This is critical for accurate temporal ordering in
search and recent_activity queries.
"""
project_dir = project_config.home
# Create initial file
initial_content = """
---
type: knowledge
---
# Test File
Initial content for timestamp test
"""
file_path = project_dir / "timestamp_test.md"
await create_test_file(file_path, initial_content)
# Initial sync
await sync_service.sync(project_config.home)
# Get initial entity and timestamps
entity_before = await entity_service.get_by_permalink("timestamp-test")
initial_updated_at = entity_before.updated_at
# Modify the file content and update mtime to be newer than watermark
modified_content = """
---
type: knowledge
---
# Test File
Modified content for timestamp test
## Observations
- [test] This was modified
"""
file_path.write_text(modified_content)
# Touch file to ensure mtime is newer than watermark
# This uses our helper which sleeps 500ms and rewrites to guarantee mtime change
await touch_file(file_path)
# Get the file's modification time after our changes
file_stats_after_modification = file_path.stat()
# Force full scan to ensure the modified file is detected
# (incremental scans have timing precision issues with watermarks on some filesystems)
await force_full_scan(sync_service)
# Re-sync the modified file
await sync_service.sync(project_config.home)
# Get entity after re-sync
entity_after = await entity_service.get_by_permalink("timestamp-test")
# Verify that updated_at changed
assert entity_after.updated_at != initial_updated_at, (
"updated_at should change when file is modified"
)
# Verify that updated_at matches the file's modification time, not db operation time
entity_updated_epoch = entity_after.updated_at.timestamp()
file_mtime = file_stats_after_modification.st_mtime
# Allow 2s difference on Windows due to filesystem timing precision
tolerance = 2 if os.name == "nt" else 1
assert abs(entity_updated_epoch - file_mtime) < tolerance, (
f"Entity updated_at ({entity_after.updated_at}) should match file mtime "
f"({datetime.fromtimestamp(file_mtime)}) within {tolerance}s tolerance"
)
# Verify the content was actually updated
assert len(entity_after.observations) == 1
assert entity_after.observations[0].content == "This was modified"
@pytest.mark.asyncio
async def test_file_move_updates_search_index(
sync_service: SyncService,
@@ -673,7 +789,11 @@ Content for move test
new_path.parent.mkdir(parents=True)
old_path.rename(new_path)
# Sync again
# Force full scan to detect the move
# (rename doesn't update mtime, so incremental scan won't find it)
await force_full_scan(sync_service)
# Second sync should detect the move
await sync_service.sync(project_config.home)
# Check search index has updated path
@@ -687,7 +807,6 @@ async def test_sync_null_checksum_cleanup(
sync_service: SyncService,
project_config: ProjectConfig,
entity_service: EntityService,
app_config,
):
"""Test handling of entities with null checksums from incomplete syncs."""
# Create entity with null checksum (simulating incomplete sync)
@@ -753,6 +872,10 @@ Content for move test
new_path.parent.mkdir(parents=True)
old_path.rename(new_path)
# Force full scan to detect the move
# (rename doesn't update mtime, so incremental scan won't find it)
await force_full_scan(sync_service)
# Sync again
await sync_service.sync(project_config.home)
@@ -772,6 +895,10 @@ Content for move test
old_path.parent.mkdir(parents=True, exist_ok=True)
await create_test_file(old_path, content)
# Force full scan to detect the new file
# (file just created may not be newer than watermark due to timing precision)
await force_full_scan(sync_service)
# Sync new file
await sync_service.sync(project_config.home)
@@ -837,6 +964,10 @@ test content
"""
two_file.write_text(updated_content)
# Force full scan to detect the modified file
# (file just modified may not be newer than watermark due to timing precision)
await force_full_scan(sync_service)
# Run sync
await sync_service.sync(project_config.home)
@@ -858,6 +989,10 @@ test content
new_file = project_dir / "new.md"
await create_test_file(new_file, new_content)
# Force full scan to detect the new file
# (file just created may not be newer than watermark due to timing precision)
await force_full_scan(sync_service)
# Run another time
await sync_service.sync(project_config.home)
@@ -902,7 +1037,6 @@ async def test_sync_permalink_updated_on_move(
):
"""Test that we update a permalink on a file move if set in config ."""
project_dir = project_config.home
sync_service.project_config = project_config
# Create initial file
content = dedent(
@@ -931,6 +1065,10 @@ async def test_sync_permalink_updated_on_move(
new_path.parent.mkdir(parents=True)
old_path.rename(new_path)
# Force full scan to detect the move
# (rename doesn't update mtime, so incremental scan won't find it)
await force_full_scan(sync_service)
# Sync again
await sync_service.sync(project_config.home)
@@ -974,6 +1112,10 @@ async def test_sync_non_markdown_files_modified(
test_files["pdf"].write_text("New content")
test_files["image"].write_text("New content")
# Force full scan to detect the modified files
# (files just modified may not be newer than watermark due to timing precision)
await force_full_scan(sync_service)
report = await sync_service.sync(project_config.home)
assert len(report.modified) == 2
@@ -1000,6 +1142,11 @@ async def test_sync_non_markdown_files_move(sync_service, project_config, test_f
assert test_files["image"].name in [f for f in report.new]
test_files["pdf"].rename(project_config.home / "moved_pdf.pdf")
# Force full scan to detect the move
# (rename doesn't update mtime, so incremental scan won't find it)
await force_full_scan(sync_service)
report2 = await sync_service.sync(project_config.home)
assert len(report2.moves) == 1
@@ -1126,7 +1273,7 @@ This is a test file for race condition handling.
call_count += 1
if call_count == 1:
# Simulate race condition - another process created the entity
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None) # pyright: ignore [reportArgumentType]
else:
return await original_add(*args, **kwargs)
@@ -1208,7 +1355,7 @@ This is a test file for integrity error handling.
# Mock the entity_repository.add to raise a different IntegrityError (not file_path constraint)
async def mock_add(*args, **kwargs):
# Simulate a different constraint violation
raise IntegrityError("UNIQUE constraint failed: entity.some_other_field", None, None)
raise IntegrityError("UNIQUE constraint failed: entity.some_other_field", None, None) # pyright: ignore [reportArgumentType]
with patch.object(sync_service.entity_repository, "add", side_effect=mock_add):
# Should re-raise the IntegrityError since it's not a file_path constraint
@@ -1241,7 +1388,7 @@ This is a test file for entity not found after constraint violation.
# Mock the entity_repository.add to raise IntegrityError
async def mock_add(*args, **kwargs):
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None) # pyright: ignore [reportArgumentType]
# Mock get_by_file_path to return None (entity not found)
async def mock_get_by_file_path(file_path):
@@ -1282,7 +1429,7 @@ This is a test file for update failure after constraint violation.
# Mock the entity_repository.add to raise IntegrityError
async def mock_add(*args, **kwargs):
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None)
raise IntegrityError("UNIQUE constraint failed: entity.file_path", None, None) # pyright: ignore [reportArgumentType]
# Mock get_by_file_path to return an existing entity
async def mock_get_by_file_path(file_path):
@@ -1316,3 +1463,511 @@ This is a test file for update failure after constraint violation.
await sync_service.sync_regular_file(
str(test_file.relative_to(project_config.home)), new=True
)
@pytest.mark.asyncio
async def test_circuit_breaker_skips_after_three_failures(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that circuit breaker skips file after 3 consecutive failures."""
from unittest.mock import patch
project_dir = project_config.home
test_file = project_dir / "failing_file.md"
# Create a file with malformed content that will fail to parse
await create_test_file(test_file, "invalid markdown content")
# Mock sync_markdown_file to always fail
async def mock_sync_markdown_file(*args, **kwargs):
raise ValueError("Simulated sync failure")
with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
# First sync - should fail and record (1/3)
report1 = await sync_service.sync(project_dir)
assert len(report1.skipped_files) == 0 # Not skipped yet
# Touch file to trigger incremental scan
await touch_file(test_file)
# Force full scan to ensure file is detected
# (touch may not update mtime sufficiently on all filesystems)
await force_full_scan(sync_service)
# Second sync - should fail and record (2/3)
report2 = await sync_service.sync(project_dir)
assert len(report2.skipped_files) == 0 # Still not skipped
# Touch file to trigger incremental scan
await touch_file(test_file)
# Force full scan to ensure file is detected
# (touch may not update mtime sufficiently on all filesystems)
await force_full_scan(sync_service)
# Third sync - should fail, record (3/3), and be added to skipped list
report3 = await sync_service.sync(project_dir)
assert len(report3.skipped_files) == 1
assert report3.skipped_files[0].path == "failing_file.md"
assert report3.skipped_files[0].failure_count == 3
assert "Simulated sync failure" in report3.skipped_files[0].reason
# Touch file to trigger incremental scan
await touch_file(test_file)
# Force full scan to ensure file is detected
# (touch may not update mtime sufficiently on all filesystems)
await force_full_scan(sync_service)
# Fourth sync - should be skipped immediately without attempting
report4 = await sync_service.sync(project_dir)
assert len(report4.skipped_files) == 1 # Still skipped
@pytest.mark.asyncio
async def test_circuit_breaker_resets_on_file_change(
sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
):
"""Test that circuit breaker resets when file content changes."""
from unittest.mock import patch
project_dir = project_config.home
test_file = project_dir / "changing_file.md"
# Create initial failing content
await create_test_file(test_file, "initial bad content")
# Mock sync_markdown_file to fail
call_count = 0
async def mock_sync_markdown_file(*args, **kwargs):
nonlocal call_count
call_count += 1
raise ValueError("Simulated sync failure")
with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
# Fail 3 times to hit circuit breaker threshold
await sync_service.sync(project_dir) # Fail 1
await touch_file(test_file) # Touch to trigger incremental scan
# Force full scan to ensure file is detected
# (touch may not update mtime sufficiently on all filesystems)
await force_full_scan(sync_service)
await sync_service.sync(project_dir) # Fail 2
await touch_file(test_file) # Touch to trigger incremental scan
# Force full scan to ensure file is detected
# (touch may not update mtime sufficiently on all filesystems)
await force_full_scan(sync_service)
report3 = await sync_service.sync(project_dir) # Fail 3 - now skipped
assert len(report3.skipped_files) == 1
# Now change the file content
valid_content = dedent(
"""
---
title: Fixed Content
type: knowledge
---
# Fixed Content
This should work now.
"""
).strip()
await create_test_file(test_file, valid_content)
# Force full scan to detect the modified file
# (file just modified may not be newer than watermark due to timing precision)
await force_full_scan(sync_service)
# Circuit breaker should reset and allow retry
report = await sync_service.sync(project_dir)
assert len(report.skipped_files) == 0 # Should not be skipped anymore
# Verify entity was created successfully
entity = await entity_service.get_by_permalink("changing-file")
assert entity is not None
assert entity.title == "Fixed Content"
@pytest.mark.asyncio
async def test_circuit_breaker_clears_on_success(
sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
):
"""Test that circuit breaker clears failure history after successful sync."""
from unittest.mock import patch
project_dir = project_config.home
test_file = project_dir / "sometimes_failing.md"
valid_content = dedent(
"""
---
title: Test File
type: knowledge
---
# Test File
Test content
"""
).strip()
await create_test_file(test_file, valid_content)
# Mock to fail twice, then succeed
call_count = 0
original_sync_markdown_file = sync_service.sync_markdown_file
async def mock_sync_markdown_file(path, new):
nonlocal call_count
call_count += 1
if call_count <= 2:
raise ValueError("Temporary failure")
# On third call, use the real implementation
return await original_sync_markdown_file(path, new)
# Patch and fail twice
with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
await sync_service.sync(project_dir) # Fail 1
await touch_file(test_file) # Touch to trigger incremental scan
# Force full scan to ensure file is detected
# (touch may not update mtime sufficiently on all filesystems)
await force_full_scan(sync_service)
await sync_service.sync(project_dir) # Fail 2
await touch_file(test_file) # Touch to trigger incremental scan
# Force full scan to ensure file is detected
# (touch may not update mtime sufficiently on all filesystems)
await force_full_scan(sync_service)
await sync_service.sync(project_dir) # Succeed
# Verify failure history was cleared
assert "sometimes_failing.md" not in sync_service._file_failures
# Verify entity was created
entity = await entity_service.get_by_permalink("sometimes-failing")
assert entity is not None
@pytest.mark.asyncio
async def test_circuit_breaker_handles_checksum_computation_failure(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test circuit breaker behavior when checksum computation fails."""
from unittest.mock import patch
project_dir = project_config.home
test_file = project_dir / "checksum_fail.md"
await create_test_file(test_file, "content")
# Mock sync_markdown_file to fail
async def mock_sync_markdown_file(*args, **kwargs):
raise ValueError("Sync failure")
# Mock checksum computation to fail only during _record_failure (not during scan)
original_compute_checksum = sync_service.file_service.compute_checksum
call_count = 0
async def mock_compute_checksum(path):
nonlocal call_count
call_count += 1
# First call is during scan - let it succeed
if call_count == 1:
return await original_compute_checksum(path)
# Second call is during _record_failure - make it fail
raise IOError("Cannot read file")
with (
patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file),
patch.object(
sync_service.file_service,
"compute_checksum",
side_effect=mock_compute_checksum,
),
):
# Should still record failure even if checksum fails
await sync_service.sync(project_dir)
# Check that failure was recorded with empty checksum
assert "checksum_fail.md" in sync_service._file_failures
failure_info = sync_service._file_failures["checksum_fail.md"]
assert failure_info.count == 1
assert failure_info.last_checksum == "" # Empty when checksum fails
@pytest.mark.asyncio
async def test_sync_fatal_error_terminates_sync_immediately(
sync_service: SyncService, project_config: ProjectConfig, entity_service: EntityService
):
"""Test that SyncFatalError terminates sync immediately without circuit breaker retry.
This tests the fix for issue #188 where project deletion during sync should
terminate immediately rather than retrying each file 3 times.
"""
from unittest.mock import patch
from basic_memory.services.exceptions import SyncFatalError
project_dir = project_config.home
# Create multiple test files
await create_test_file(
project_dir / "file1.md",
dedent(
"""
---
type: knowledge
---
# File 1
Content 1
"""
),
)
await create_test_file(
project_dir / "file2.md",
dedent(
"""
---
type: knowledge
---
# File 2
Content 2
"""
),
)
await create_test_file(
project_dir / "file3.md",
dedent(
"""
---
type: knowledge
---
# File 3
Content 3
"""
),
)
# Mock entity_service.create_entity_from_markdown to raise SyncFatalError on first file
# This simulates project being deleted during sync
async def mock_create_entity_from_markdown(*args, **kwargs):
raise SyncFatalError(
"Cannot sync file 'file1.md': project_id=99999 does not exist in database. "
"The project may have been deleted. This sync will be terminated."
)
with patch.object(
entity_service, "create_entity_from_markdown", side_effect=mock_create_entity_from_markdown
):
# Sync should raise SyncFatalError and terminate immediately
with pytest.raises(SyncFatalError, match="project_id=99999 does not exist"):
await sync_service.sync(project_dir)
# Verify that circuit breaker did NOT record this as a file-level failure
# (SyncFatalError should bypass circuit breaker and re-raise immediately)
assert "file1.md" not in sync_service._file_failures
# Verify that no other files were attempted (sync terminated on first error)
# If circuit breaker was used, we'd see file1 in failures
# If sync continued, we'd see attempts for file2 and file3
@pytest.mark.asyncio
async def test_scan_directory_basic(sync_service: SyncService, project_config: ProjectConfig):
"""Test basic streaming directory scan functionality."""
project_dir = project_config.home
# Create test files in different directories
await create_test_file(project_dir / "root.md", "root content")
await create_test_file(project_dir / "subdir/file1.md", "file 1 content")
await create_test_file(project_dir / "subdir/file2.md", "file 2 content")
await create_test_file(project_dir / "subdir/nested/file3.md", "file 3 content")
# Collect results from streaming iterator
results = []
async for file_path, stat_info in sync_service.scan_directory(project_dir):
rel_path = Path(file_path).relative_to(project_dir).as_posix()
results.append((rel_path, stat_info))
# Verify all files were found
file_paths = {rel_path for rel_path, _ in results}
assert "root.md" in file_paths
assert "subdir/file1.md" in file_paths
assert "subdir/file2.md" in file_paths
assert "subdir/nested/file3.md" in file_paths
assert len(file_paths) == 4
# Verify stat info is present for each file
for rel_path, stat_info in results:
assert stat_info is not None
assert stat_info.st_size > 0 # Files have content
assert stat_info.st_mtime > 0 # Have modification time
@pytest.mark.asyncio
async def test_scan_directory_respects_ignore_patterns(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that streaming scan respects .gitignore patterns."""
project_dir = project_config.home
# Create .gitignore file in project (will be used along with .bmignore)
(project_dir / ".gitignore").write_text("*.ignored\n.hidden/\n")
# Reload ignore patterns using project's .gitignore
from basic_memory.ignore_utils import load_gitignore_patterns
sync_service._ignore_patterns = load_gitignore_patterns(project_dir)
# Create test files - some should be ignored
await create_test_file(project_dir / "included.md", "included")
await create_test_file(project_dir / "excluded.ignored", "excluded")
await create_test_file(project_dir / ".hidden/secret.md", "secret")
await create_test_file(project_dir / "subdir/file.md", "file")
# Collect results
results = []
async for file_path, stat_info in sync_service.scan_directory(project_dir):
rel_path = Path(file_path).relative_to(project_dir).as_posix()
results.append(rel_path)
# Verify ignored files were not returned
assert "included.md" in results
assert "subdir/file.md" in results
assert "excluded.ignored" not in results
assert ".hidden/secret.md" not in results
assert ".bmignore" not in results # .bmignore itself should be ignored
@pytest.mark.asyncio
async def test_scan_directory_cached_stat_info(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that streaming scan provides cached stat info (no redundant stat calls)."""
project_dir = project_config.home
# Create test file
test_file = project_dir / "test.md"
await create_test_file(test_file, "test content")
# Get stat info from streaming scan
async for file_path, stat_info in sync_service.scan_directory(project_dir):
if Path(file_path).name == "test.md":
# Get independent stat for comparison
independent_stat = test_file.stat()
# Verify stat info matches (cached stat should be accurate)
assert stat_info.st_size == independent_stat.st_size
assert abs(stat_info.st_mtime - independent_stat.st_mtime) < 1 # Allow 1s tolerance
assert abs(stat_info.st_ctime - independent_stat.st_ctime) < 1
break
@pytest.mark.asyncio
async def test_scan_directory_empty_directory(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test streaming scan on empty directory (ignoring hidden files)."""
project_dir = project_config.home
# Directory exists but has no user files (may have .basic-memory config dir)
assert project_dir.exists()
# Don't create any user files - just scan empty directory
# Scan should yield no results (hidden files are ignored by default)
results = []
async for file_path, stat_info in sync_service.scan_directory(project_dir):
results.append(file_path)
# Should find no files (config dirs are hidden and ignored)
assert len(results) == 0
@pytest.mark.asyncio
async def test_scan_directory_handles_permission_error(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that streaming scan handles permission errors gracefully."""
import sys
# Skip on Windows - permission handling is different
if sys.platform == "win32":
pytest.skip("Permission tests not reliable on Windows")
project_dir = project_config.home
# Create accessible file
await create_test_file(project_dir / "accessible.md", "accessible")
# Create restricted directory
restricted_dir = project_dir / "restricted"
restricted_dir.mkdir()
await create_test_file(restricted_dir / "secret.md", "secret")
# Remove read permission from restricted directory
restricted_dir.chmod(0o000)
try:
# Scan should handle permission error and continue
results = []
async for file_path, stat_info in sync_service.scan_directory(project_dir):
rel_path = Path(file_path).relative_to(project_dir).as_posix()
results.append(rel_path)
# Should have found accessible file but not restricted one
assert "accessible.md" in results
assert "restricted/secret.md" not in results
finally:
# Restore permissions for cleanup
restricted_dir.chmod(0o755)
@pytest.mark.asyncio
async def test_scan_directory_non_markdown_files(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that streaming scan finds all file types, not just markdown."""
project_dir = project_config.home
# Create various file types
await create_test_file(project_dir / "doc.md", "markdown")
(project_dir / "image.png").write_bytes(b"PNG content")
(project_dir / "data.json").write_text('{"key": "value"}')
(project_dir / "script.py").write_text("print('hello')")
# Collect results
results = []
async for file_path, stat_info in sync_service.scan_directory(project_dir):
rel_path = Path(file_path).relative_to(project_dir).as_posix()
results.append(rel_path)
# All files should be found
assert "doc.md" in results
assert "image.png" in results
assert "data.json" in results
assert "script.py" in results
@pytest.mark.asyncio
async def test_file_service_checksum_correctness(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that FileService computes correct checksums."""
import hashlib
project_dir = project_config.home
# Test small markdown file
small_content = "Test content for checksum validation" * 10
small_file = project_dir / "small.md"
await create_test_file(small_file, small_content)
rel_path = small_file.relative_to(project_dir).as_posix()
checksum = await sync_service.file_service.compute_checksum(rel_path)
# Verify checksum is correct
expected = hashlib.sha256(small_content.encode("utf-8")).hexdigest()
assert checksum == expected
assert len(checksum) == 64 # SHA256 hex digest length
+790
View File
@@ -0,0 +1,790 @@
"""Tests for incremental scan watermark optimization (Phase 1.5).
These tests verify the scan watermark feature that dramatically improves sync
performance on large projects by:
- Using find -newermt for incremental scans (only changed files)
- Tracking last_scan_timestamp and last_file_count
- Falling back to full scan when deletions detected
Expected performance improvements:
- No changes: 225x faster (2s vs 450s for 1,460 files)
- Few changes: 84x faster (5s vs 420s)
"""
import time
from pathlib import Path
from textwrap import dedent
import pytest
from basic_memory.config import ProjectConfig
from basic_memory.sync.sync_service import SyncService
async def create_test_file(path: Path, content: str = "test content") -> None:
"""Create a test file with given content."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
async def sleep_past_watermark(duration: float = 1.1) -> None:
"""Sleep long enough to ensure mtime is newer than watermark.
Args:
duration: Sleep duration in seconds (default 1.1s for filesystem precision)
"""
time.sleep(duration)
# ==============================================================================
# Scan Strategy Selection Tests
# ==============================================================================
@pytest.mark.asyncio
async def test_first_sync_uses_full_scan(sync_service: SyncService, project_config: ProjectConfig):
"""Test that first sync (no watermark) triggers full scan."""
project_dir = project_config.home
# Create test files
await create_test_file(project_dir / "file1.md", "# File 1\nContent 1")
await create_test_file(project_dir / "file2.md", "# File 2\nContent 2")
# First sync - should use full scan (no watermark exists)
report = await sync_service.sync(project_dir)
assert len(report.new) == 2
assert "file1.md" in report.new
assert "file2.md" in report.new
# Verify watermark was set
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project.last_scan_timestamp is not None
assert project.last_file_count >= 2 # May include config files
@pytest.mark.asyncio
async def test_file_count_decreased_triggers_full_scan(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that file deletion (count decreased) triggers full scan."""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1")
await create_test_file(project_dir / "file2.md", "# File 2")
await create_test_file(project_dir / "file3.md", "# File 3")
# First sync
await sync_service.sync(project_dir)
# Delete a file
(project_dir / "file2.md").unlink()
# Sleep to ensure file operations complete
await sleep_past_watermark()
# Second sync - should detect deletion via full scan (file count decreased)
report = await sync_service.sync(project_dir)
assert len(report.deleted) == 1
assert "file2.md" in report.deleted
@pytest.mark.asyncio
async def test_file_count_same_uses_incremental_scan(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that same file count uses incremental scan."""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1\nOriginal")
await create_test_file(project_dir / "file2.md", "# File 2\nOriginal")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure mtime will be newer than watermark
await sleep_past_watermark()
# Modify one file (file count stays the same)
await create_test_file(project_dir / "file1.md", "# File 1\nModified")
# Second sync - should use incremental scan (same file count)
report = await sync_service.sync(project_dir)
assert len(report.modified) == 1
assert "file1.md" in report.modified
@pytest.mark.asyncio
async def test_file_count_increased_uses_incremental_scan(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that increased file count still uses incremental scan."""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1")
await create_test_file(project_dir / "file2.md", "# File 2")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure mtime will be newer than watermark
await sleep_past_watermark()
# Add a new file (file count increased)
await create_test_file(project_dir / "file3.md", "# File 3")
# Second sync - should use incremental scan and detect new file
report = await sync_service.sync(project_dir)
assert len(report.new) == 1
assert "file3.md" in report.new
@pytest.mark.asyncio
async def test_force_full_bypasses_watermark_optimization(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that force_full=True bypasses watermark optimization and scans all files.
This is critical for detecting changes made by external tools like rclone bisync
that don't update mtimes detectably. See issue #407.
"""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1\nOriginal")
await create_test_file(project_dir / "file2.md", "# File 2\nOriginal")
# First sync - establishes watermark
report = await sync_service.sync(project_dir)
assert len(report.new) == 2
# Verify watermark was set
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project.last_scan_timestamp is not None
initial_timestamp = project.last_scan_timestamp
# Sleep to ensure time passes
await sleep_past_watermark()
# Modify a file WITHOUT updating mtime (simulates external tool like rclone)
# We set mtime to be BEFORE the watermark to ensure incremental scan won't detect it
file_path = project_dir / "file1.md"
file_path.stat()
await create_test_file(file_path, "# File 1\nModified by external tool")
# Set mtime to be before the watermark (use time from before first sync)
# This simulates rclone bisync which may preserve original timestamps
import os
old_time = initial_timestamp - 10 # 10 seconds before watermark
os.utime(file_path, (old_time, old_time))
# Normal incremental sync should NOT detect the change (mtime before watermark)
report = await sync_service.sync(project_dir)
assert len(report.modified) == 0, (
"Incremental scan should not detect changes with mtime older than watermark"
)
# Force full scan should detect the change via checksum comparison
report = await sync_service.sync(project_dir, force_full=True)
assert len(report.modified) == 1, "Force full scan should detect changes via checksum"
assert "file1.md" in report.modified
# Verify watermark was still updated after force_full
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project.last_scan_timestamp is not None
assert project.last_scan_timestamp > initial_timestamp
# ==============================================================================
# Incremental Scan Base Cases
# ==============================================================================
@pytest.mark.asyncio
async def test_incremental_scan_no_changes(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that incremental scan with no changes returns empty report."""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1")
await create_test_file(project_dir / "file2.md", "# File 2")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure time passes
await sleep_past_watermark()
# Second sync - no changes
report = await sync_service.sync(project_dir)
assert len(report.new) == 0
assert len(report.modified) == 0
assert len(report.deleted) == 0
assert len(report.moves) == 0
@pytest.mark.asyncio
async def test_incremental_scan_detects_new_file(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that incremental scan detects newly created files."""
project_dir = project_config.home
# Create initial file
await create_test_file(project_dir / "file1.md", "# File 1")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure mtime will be newer than watermark
await sleep_past_watermark()
# Create new file
await create_test_file(project_dir / "file2.md", "# File 2")
# Second sync - should detect new file via incremental scan
report = await sync_service.sync(project_dir)
assert len(report.new) == 1
assert "file2.md" in report.new
assert len(report.modified) == 0
@pytest.mark.asyncio
async def test_incremental_scan_detects_modified_file(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that incremental scan detects modified files."""
project_dir = project_config.home
# Create initial files
file_path = project_dir / "file1.md"
await create_test_file(file_path, "# File 1\nOriginal content")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure mtime will be newer than watermark
await sleep_past_watermark()
# Modify the file
await create_test_file(file_path, "# File 1\nModified content")
# Second sync - should detect modification via incremental scan
report = await sync_service.sync(project_dir)
assert len(report.modified) == 1
assert "file1.md" in report.modified
assert len(report.new) == 0
@pytest.mark.asyncio
async def test_incremental_scan_detects_multiple_changes(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that incremental scan detects multiple file changes."""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1\nOriginal")
await create_test_file(project_dir / "file2.md", "# File 2\nOriginal")
await create_test_file(project_dir / "file3.md", "# File 3\nOriginal")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure mtime will be newer than watermark
await sleep_past_watermark()
# Modify multiple files
await create_test_file(project_dir / "file1.md", "# File 1\nModified")
await create_test_file(project_dir / "file3.md", "# File 3\nModified")
await create_test_file(project_dir / "file4.md", "# File 4\nNew")
# Second sync - should detect all changes via incremental scan
report = await sync_service.sync(project_dir)
assert len(report.modified) == 2
assert "file1.md" in report.modified
assert "file3.md" in report.modified
assert len(report.new) == 1
assert "file4.md" in report.new
# ==============================================================================
# Deletion Detection Tests
# ==============================================================================
@pytest.mark.asyncio
async def test_deletion_triggers_full_scan_single_file(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that deleting a single file triggers full scan."""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1")
await create_test_file(project_dir / "file2.md", "# File 2")
await create_test_file(project_dir / "file3.md", "# File 3")
# First sync
report1 = await sync_service.sync(project_dir)
assert len(report1.new) == 3
# Delete one file
(project_dir / "file2.md").unlink()
# Sleep to ensure file operations complete
await sleep_past_watermark()
# Second sync - should trigger full scan due to decreased file count
report2 = await sync_service.sync(project_dir)
assert len(report2.deleted) == 1
assert "file2.md" in report2.deleted
@pytest.mark.asyncio
async def test_deletion_triggers_full_scan_multiple_files(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that deleting multiple files triggers full scan."""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1")
await create_test_file(project_dir / "file2.md", "# File 2")
await create_test_file(project_dir / "file3.md", "# File 3")
await create_test_file(project_dir / "file4.md", "# File 4")
# First sync
await sync_service.sync(project_dir)
# Delete multiple files
(project_dir / "file2.md").unlink()
(project_dir / "file4.md").unlink()
# Sleep to ensure file operations complete
await sleep_past_watermark()
# Second sync - should trigger full scan and detect both deletions
report = await sync_service.sync(project_dir)
assert len(report.deleted) == 2
assert "file2.md" in report.deleted
assert "file4.md" in report.deleted
# ==============================================================================
# Move Detection Tests
# ==============================================================================
@pytest.mark.asyncio
async def test_move_detection_requires_full_scan(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that file moves require full scan to be detected (cannot detect in incremental).
Moves (renames) don't update mtime, so incremental scans can't detect them.
To trigger a full scan for move detection, we need file count to decrease.
This test verifies moves are detected when combined with a deletion.
"""
project_dir = project_config.home
# Create initial files - include extra file to delete later
old_path = project_dir / "old" / "file.md"
content = dedent(
"""
---
title: Test File
type: note
---
# Test File
Distinctive content for move detection
"""
).strip()
await create_test_file(old_path, content)
await create_test_file(project_dir / "other.md", "# Other\nContent")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure operations complete and watermark is in the past
await sleep_past_watermark()
# Move file AND delete another to trigger full scan
# Move alone won't work because file count stays same (no full scan)
new_path = project_dir / "new" / "moved.md"
new_path.parent.mkdir(parents=True, exist_ok=True)
old_path.rename(new_path)
(project_dir / "other.md").unlink() # Delete to trigger full scan
# Second sync - full scan due to deletion, move detected via checksum
report = await sync_service.sync(project_dir)
assert len(report.moves) == 1
assert "old/file.md" in report.moves
assert report.moves["old/file.md"] == "new/moved.md"
assert len(report.deleted) == 1
assert "other.md" in report.deleted
@pytest.mark.asyncio
async def test_move_detection_in_full_scan(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that file moves are detected via checksum in full scan."""
project_dir = project_config.home
# Create initial files
old_path = project_dir / "old" / "file.md"
content = dedent(
"""
---
title: Test File
type: note
---
# Test File
Distinctive content for move detection
"""
).strip()
await create_test_file(old_path, content)
await create_test_file(project_dir / "other.md", "# Other\nContent")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure operations complete
await sleep_past_watermark()
# Move file AND delete another to trigger full scan
new_path = project_dir / "new" / "moved.md"
new_path.parent.mkdir(parents=True, exist_ok=True)
old_path.rename(new_path)
(project_dir / "other.md").unlink()
# Second sync - full scan due to deletion, should still detect move
report = await sync_service.sync(project_dir)
assert len(report.moves) == 1
assert "old/file.md" in report.moves
assert report.moves["old/file.md"] == "new/moved.md"
assert len(report.deleted) == 1
assert "other.md" in report.deleted
# ==============================================================================
# Watermark Update Tests
# ==============================================================================
@pytest.mark.asyncio
async def test_watermark_updated_after_successful_sync(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that watermark is updated after each successful sync."""
project_dir = project_config.home
# Create initial file
await create_test_file(project_dir / "file1.md", "# File 1")
# Get project before sync
project_before = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project_before.last_scan_timestamp is None
assert project_before.last_file_count is None
# First sync
sync_start = time.time()
await sync_service.sync(project_dir)
sync_end = time.time()
# Verify watermark was set
project_after = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project_after.last_scan_timestamp is not None
assert project_after.last_file_count >= 1 # May include config files
# Watermark should be between sync start and end
assert sync_start <= project_after.last_scan_timestamp <= sync_end
@pytest.mark.asyncio
async def test_watermark_uses_sync_start_time(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that watermark uses sync start time, not end time."""
project_dir = project_config.home
# Create initial file
await create_test_file(project_dir / "file1.md", "# File 1")
# First sync - capture timestamps
sync_start = time.time()
await sync_service.sync(project_dir)
sync_end = time.time()
# Get watermark
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
# Watermark should be closer to start than end
# (In practice, watermark == sync_start_timestamp captured in sync())
time_from_start = abs(project.last_scan_timestamp - sync_start)
time_from_end = abs(project.last_scan_timestamp - sync_end)
assert time_from_start < time_from_end
@pytest.mark.asyncio
async def test_watermark_file_count_accurate(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that watermark file count accurately reflects synced files."""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1")
await create_test_file(project_dir / "file2.md", "# File 2")
await create_test_file(project_dir / "file3.md", "# File 3")
# First sync
await sync_service.sync(project_dir)
# Verify file count
project1 = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
initial_count = project1.last_file_count
assert initial_count >= 3 # May include config files
# Add more files
await sleep_past_watermark()
await create_test_file(project_dir / "file4.md", "# File 4")
await create_test_file(project_dir / "file5.md", "# File 5")
# Second sync
await sync_service.sync(project_dir)
# Verify updated count increased by 2
project2 = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project2.last_file_count == initial_count + 2
# ==============================================================================
# Edge Cases and Error Handling
# ==============================================================================
@pytest.mark.asyncio
async def test_concurrent_file_changes_handled_gracefully(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that files created/modified during sync are handled correctly.
Files created during sync (between start and file processing) should be
caught in the next sync, not cause errors in the current sync.
"""
project_dir = project_config.home
# Create initial file
await create_test_file(project_dir / "file1.md", "# File 1")
# First sync
await sync_service.sync(project_dir)
# Sleep to ensure mtime will be newer
await sleep_past_watermark()
# Create file that will have mtime very close to watermark
# In real scenarios, this could be created during sync
await create_test_file(project_dir / "concurrent.md", "# Concurrent")
# Should be caught in next sync without errors
report = await sync_service.sync(project_dir)
assert "concurrent.md" in report.new
@pytest.mark.asyncio
async def test_empty_directory_handles_incremental_scan(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that incremental scan handles empty directories correctly."""
project_dir = project_config.home
# First sync with empty directory (no user files)
report1 = await sync_service.sync(project_dir)
assert len(report1.new) == 0
# Verify watermark was set even for empty directory
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project.last_scan_timestamp is not None
# May have config files, so just check it's set
assert project.last_file_count is not None
# Second sync - still empty (no new user files)
report2 = await sync_service.sync(project_dir)
assert len(report2.new) == 0
@pytest.mark.asyncio
async def test_incremental_scan_respects_gitignore(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that incremental scan respects .gitignore patterns."""
project_dir = project_config.home
# Create .gitignore
(project_dir / ".gitignore").write_text("*.ignored\n.hidden/\n")
# Reload ignore patterns
from basic_memory.ignore_utils import load_gitignore_patterns
sync_service._ignore_patterns = load_gitignore_patterns(project_dir)
# Create files - some should be ignored
await create_test_file(project_dir / "included.md", "# Included")
await create_test_file(project_dir / "excluded.ignored", "# Excluded")
# First sync
report1 = await sync_service.sync(project_dir)
assert "included.md" in report1.new
assert "excluded.ignored" not in report1.new
# Sleep and add more files
await sleep_past_watermark()
await create_test_file(project_dir / "included2.md", "# Included 2")
await create_test_file(project_dir / "excluded2.ignored", "# Excluded 2")
# Second sync - incremental scan should also respect ignore patterns
report2 = await sync_service.sync(project_dir)
assert "included2.md" in report2.new
assert "excluded2.ignored" not in report2.new
# ==============================================================================
# Relation Resolution Optimization Tests
# ==============================================================================
@pytest.mark.asyncio
async def test_relation_resolution_skipped_when_no_changes(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that relation resolution is skipped when no file changes detected.
This optimization prevents wasting time resolving relations when there are
no changes, dramatically improving sync performance for large projects.
"""
project_dir = project_config.home
# Create initial file with wikilink
content = dedent(
"""
---
title: File with Link
type: note
---
# File with Link
This links to [[Target File]]
"""
).strip()
await create_test_file(project_dir / "file1.md", content)
# First sync - will resolve relations (or leave unresolved)
report1 = await sync_service.sync(project_dir)
assert len(report1.new) == 1
# Check that there are unresolved relations (target doesn't exist)
unresolved = await sync_service.relation_repository.find_unresolved_relations()
unresolved_count_before = len(unresolved)
assert unresolved_count_before > 0 # Should have unresolved relation to [[Target File]]
# Sleep to ensure time passes
await sleep_past_watermark()
# Second sync - no changes, should skip relation resolution
report2 = await sync_service.sync(project_dir)
assert report2.total == 0 # No changes detected
# Verify unresolved relations count unchanged (resolution was skipped)
unresolved_after = await sync_service.relation_repository.find_unresolved_relations()
assert len(unresolved_after) == unresolved_count_before
@pytest.mark.asyncio
async def test_relation_resolution_runs_when_files_modified(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that relation resolution runs when files are actually modified."""
project_dir = project_config.home
# Create file with unresolved wikilink
content1 = dedent(
"""
---
title: File with Link
type: note
---
# File with Link
This links to [[Target File]]
"""
).strip()
await create_test_file(project_dir / "file1.md", content1)
# First sync
await sync_service.sync(project_dir)
# Verify unresolved relation exists
unresolved_before = await sync_service.relation_repository.find_unresolved_relations()
assert len(unresolved_before) > 0
# Sleep to ensure mtime will be newer
await sleep_past_watermark()
# Create the target file (should resolve the relation)
content2 = dedent(
"""
---
title: Target File
type: note
---
# Target File
This is the target.
"""
).strip()
await create_test_file(project_dir / "target.md", content2)
# Second sync - should detect new file and resolve relations
report = await sync_service.sync(project_dir)
assert len(report.new) == 1
assert "target.md" in report.new
# Verify relation was resolved (unresolved count decreased)
unresolved_after = await sync_service.relation_repository.find_unresolved_relations()
assert len(unresolved_after) < len(unresolved_before)
+20
View File
@@ -13,6 +13,22 @@ async def create_test_file(path: Path, content: str) -> None:
path.write_text(content)
async def force_full_scan(sync_service: SyncService) -> None:
"""Force next sync to do a full scan by clearing watermark (for testing moves/deletions)."""
if sync_service.entity_repository.project_id is not None:
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
if project:
await sync_service.project_repository.update(
project.id,
{
"last_scan_timestamp": None,
"last_file_count": None,
},
)
@pytest.mark.asyncio
async def test_wikilink_modified_status_issue(sync_service: SyncService, project_config):
"""Test that files with wikilinks don't remain in modified status after sync."""
@@ -52,6 +68,10 @@ This is the target file.
target_file_path = project_dir / "another_file.md"
await create_test_file(target_file_path, target_content)
# Force full scan to detect the new file
# (file just created may not be newer than watermark due to timing precision)
await force_full_scan(sync_service)
# Sync again after adding target file
report3 = await sync_service.sync(project_config.home)
assert "another_file.md" in report3.new
+82
View File
@@ -448,3 +448,85 @@ def test_is_project_path(watch_service, tmp_path):
# Test the project path itself
assert watch_service.is_project_path(project, project_path) is False
@pytest.mark.asyncio
async def test_handle_changes_skips_deleted_project(
watch_service, project_config, test_project, sync_service, project_service, tmp_path
):
"""Test that handle_changes skips processing changes for projects that have been deleted.
This is a regression test for issue #193 where deleted projects were being recreated
by background sync because the directory still existed on disk.
"""
from textwrap import dedent
project_dir = project_config.home
# Create a test file in the project
test_file = project_dir / "test_note.md"
content = dedent("""
---
type: knowledge
---
# Test Note
Test content
""").strip()
await create_test_file(test_file, content)
# Initial sync to create the entity
await sync_service.sync(project_dir)
# Verify entity was created
entity_before = await sync_service.entity_repository.get_by_file_path("test_note.md")
assert entity_before is not None
# Create a second project directly in the database and set it as default
# so we can remove the first one (cannot remove default project)
other_project_path = str(tmp_path.parent / "other-project-for-test")
project_data = {
"name": "other-project",
"path": other_project_path,
"permalink": "other-project",
"is_active": True,
}
other_project = await project_service.repository.create(project_data)
await project_service.repository.set_as_default(other_project.id)
# Also add to config
config = project_service.config_manager.load_config()
config.projects["other-project"] = other_project_path
config.default_project = "other-project"
project_service.config_manager.save_config(config)
# Remove the test project from configuration (simulating project deletion)
# This should prevent background sync from processing changes
await project_service.remove_project(test_project.name)
# Simulate file changes after project deletion
# These changes should be ignored by the watch service
modified_content = dedent("""
---
type: knowledge
---
# Test Note
Modified content after project deletion
""").strip()
await create_test_file(test_file, modified_content)
changes = {(Change.modified, str(test_file))}
# Handle changes - should skip processing since project is deleted
await watch_service.handle_changes(test_project, changes)
# Verify that the entity was NOT re-created or updated
# Since the project was deleted, the database should still have the old state
# or the entity should be gone entirely if cleanup happened
entity_after = await sync_service.entity_repository.get_by_file_path("test_note.md")
# The entity might be deleted or unchanged, but it should not be updated with new content
if entity_after is not None:
# If the entity still exists, it should have the old content, not the new content
assert entity_after.checksum == entity_before.checksum, (
"Entity should not be updated for deleted project"
)
+100 -1
View File
@@ -2,8 +2,9 @@
import tempfile
import pytest
from datetime import datetime
from basic_memory.config import BasicMemoryConfig, ConfigManager
from basic_memory.config import BasicMemoryConfig, CloudProjectConfig, ConfigManager
from pathlib import Path
@@ -268,3 +269,101 @@ class TestConfigManager:
# Try to remove the default project
with pytest.raises(ValueError, match="Cannot remove the default project"):
config_manager.remove_project("main")
def test_config_with_cloud_projects_empty_by_default(self, temp_config_manager):
"""Test that cloud_projects field exists and defaults to empty dict."""
config_manager = temp_config_manager
config = config_manager.load_config()
assert hasattr(config, "cloud_projects")
assert config.cloud_projects == {}
def test_save_and_load_config_with_cloud_projects(self):
"""Test that config with cloud_projects can be saved and loaded."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create config with cloud_projects
now = datetime.now()
test_config = BasicMemoryConfig(
projects={"main": str(temp_path / "main")},
cloud_projects={
"research": CloudProjectConfig(
local_path=str(temp_path / "research-local"),
last_sync=now,
bisync_initialized=True,
)
},
)
config_manager.save_config(test_config)
# Load and verify
loaded_config = config_manager.load_config()
assert "research" in loaded_config.cloud_projects
assert loaded_config.cloud_projects["research"].local_path == str(
temp_path / "research-local"
)
assert loaded_config.cloud_projects["research"].bisync_initialized is True
assert loaded_config.cloud_projects["research"].last_sync == now
def test_add_cloud_project_to_existing_config(self):
"""Test adding cloud projects to an existing config file."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Create initial config without cloud projects
initial_config = BasicMemoryConfig(projects={"main": str(temp_path / "main")})
config_manager.save_config(initial_config)
# Load, modify, and save
config = config_manager.load_config()
assert config.cloud_projects == {}
config.cloud_projects["work"] = CloudProjectConfig(
local_path=str(temp_path / "work-local")
)
config_manager.save_config(config)
# Reload and verify persistence
reloaded_config = config_manager.load_config()
assert "work" in reloaded_config.cloud_projects
assert reloaded_config.cloud_projects["work"].local_path == str(
temp_path / "work-local"
)
assert reloaded_config.cloud_projects["work"].bisync_initialized is False
def test_backward_compatibility_loading_config_without_cloud_projects(self):
"""Test that old config files without cloud_projects field can be loaded."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
config_manager = ConfigManager()
config_manager.config_dir = temp_path / "basic-memory"
config_manager.config_file = config_manager.config_dir / "config.json"
config_manager.config_dir.mkdir(parents=True, exist_ok=True)
# Manually write old-style config without cloud_projects
import json
old_config_data = {
"env": "dev",
"projects": {"main": str(temp_path / "main")},
"default_project": "main",
"log_level": "INFO",
}
config_manager.config_file.write_text(json.dumps(old_config_data, indent=2))
# Should load successfully with cloud_projects defaulting to empty dict
config = config_manager.load_config()
assert config.cloud_projects == {}
assert config.projects == {"main": str(temp_path / "main")}
+353
View File
@@ -0,0 +1,353 @@
"""Test project-scoped rclone commands."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from basic_memory.cli.commands.cloud.rclone_commands import (
RcloneError,
SyncProject,
bisync_initialized,
get_project_bisync_state,
get_project_remote,
project_bisync,
project_check,
project_ls,
project_sync,
)
def test_sync_project_dataclass():
"""Test SyncProject dataclass."""
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/Users/test/research",
)
assert project.name == "research"
assert project.path == "app/data/research"
assert project.local_sync_path == "/Users/test/research"
def test_sync_project_optional_local_path():
"""Test SyncProject with optional local_sync_path."""
project = SyncProject(
name="research",
path="app/data/research",
)
assert project.name == "research"
assert project.path == "app/data/research"
assert project.local_sync_path is None
def test_get_project_remote():
"""Test building rclone remote path with normalized path."""
# Path comes from API already normalized (no /app/data/ prefix)
project = SyncProject(name="research", path="/research")
remote = get_project_remote(project, "my-bucket")
assert remote == "basic-memory-cloud:my-bucket/research"
def test_get_project_remote_strips_app_data_prefix():
"""Test that /app/data/ prefix is stripped from cloud path."""
# If API returns path with /app/data/, it should be stripped
project = SyncProject(name="research", path="/app/data/research")
remote = get_project_remote(project, "my-bucket")
# Should strip /app/data/ prefix to get actual S3 path
assert remote == "basic-memory-cloud:my-bucket/research"
def test_get_project_bisync_state():
"""Test getting bisync state directory path."""
state_path = get_project_bisync_state("research")
expected = Path.home() / ".basic-memory" / "bisync-state" / "research"
assert state_path == expected
def test_bisync_initialized_false_when_not_exists(tmp_path, monkeypatch):
"""Test bisync_initialized returns False when state doesn't exist."""
# Patch to use tmp directory
monkeypatch.setattr(
"basic_memory.cli.commands.cloud.rclone_commands.get_project_bisync_state",
lambda project_name: tmp_path / project_name,
)
assert bisync_initialized("research") is False
def test_bisync_initialized_false_when_empty(tmp_path, monkeypatch):
"""Test bisync_initialized returns False when state directory is empty."""
state_dir = tmp_path / "research"
state_dir.mkdir()
monkeypatch.setattr(
"basic_memory.cli.commands.cloud.rclone_commands.get_project_bisync_state",
lambda project_name: tmp_path / project_name,
)
assert bisync_initialized("research") is False
def test_bisync_initialized_true_when_has_files(tmp_path, monkeypatch):
"""Test bisync_initialized returns True when state has files."""
state_dir = tmp_path / "research"
state_dir.mkdir()
(state_dir / "state.lst").touch()
monkeypatch.setattr(
"basic_memory.cli.commands.cloud.rclone_commands.get_project_bisync_state",
lambda project_name: tmp_path / project_name,
)
assert bisync_initialized("research") is True
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_project_sync_success(mock_run):
"""Test successful project sync."""
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="/research", # Normalized path from API
local_sync_path="/tmp/research",
)
result = project_sync(project, "my-bucket", dry_run=True)
assert result is True
mock_run.assert_called_once()
# Check command arguments
cmd = mock_run.call_args[0][0]
assert cmd[0] == "rclone"
assert cmd[1] == "sync"
# Use Path for cross-platform comparison (Windows uses backslashes)
assert Path(cmd[2]) == Path("/tmp/research")
assert cmd[3] == "basic-memory-cloud:my-bucket/research"
assert "--dry-run" in cmd
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_project_sync_with_verbose(mock_run):
"""Test project sync with verbose flag."""
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
project_sync(project, "my-bucket", verbose=True)
cmd = mock_run.call_args[0][0]
assert "--verbose" in cmd
assert "--progress" not in cmd
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_project_sync_with_progress(mock_run):
"""Test project sync with progress (default)."""
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
project_sync(project, "my-bucket")
cmd = mock_run.call_args[0][0]
assert "--progress" in cmd
assert "--verbose" not in cmd
def test_project_sync_no_local_path():
"""Test project sync raises error when local_sync_path not configured."""
project = SyncProject(name="research", path="app/data/research")
with pytest.raises(RcloneError) as exc_info:
project_sync(project, "my-bucket")
assert "no local_sync_path configured" in str(exc_info.value)
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
def test_project_bisync_success(mock_bisync_init, mock_run):
"""Test successful project bisync."""
mock_bisync_init.return_value = True # Already initialized
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
result = project_bisync(project, "my-bucket")
assert result is True
mock_run.assert_called_once()
# Check command arguments
cmd = mock_run.call_args[0][0]
assert cmd[0] == "rclone"
assert cmd[1] == "bisync"
assert "--conflict-resolve=newer" in cmd
assert "--max-delete=25" in cmd
assert "--resilient" in cmd
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run):
"""Test that first bisync requires --resync flag."""
mock_bisync_init.return_value = False # Not initialized
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
with pytest.raises(RcloneError) as exc_info:
project_bisync(project, "my-bucket")
assert "requires --resync" in str(exc_info.value)
mock_run.assert_not_called()
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run):
"""Test bisync with --resync flag for first time."""
mock_bisync_init.return_value = False # Not initialized
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
result = project_bisync(project, "my-bucket", resync=True)
assert result is True
cmd = mock_run.call_args[0][0]
assert "--resync" in cmd
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
def test_project_bisync_dry_run_skips_init_check(mock_bisync_init, mock_run):
"""Test that dry-run skips initialization check."""
mock_bisync_init.return_value = False # Not initialized
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
# Should not raise error even though not initialized
result = project_bisync(project, "my-bucket", dry_run=True)
assert result is True
cmd = mock_run.call_args[0][0]
assert "--dry-run" in cmd
def test_project_bisync_no_local_path():
"""Test project bisync raises error when local_sync_path not configured."""
project = SyncProject(name="research", path="app/data/research")
with pytest.raises(RcloneError) as exc_info:
project_bisync(project, "my-bucket")
assert "no local_sync_path configured" in str(exc_info.value)
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_project_check_success(mock_run):
"""Test successful project check."""
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
result = project_check(project, "my-bucket")
assert result is True
cmd = mock_run.call_args[0][0]
assert cmd[0] == "rclone"
assert cmd[1] == "check"
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_project_check_with_one_way(mock_run):
"""Test project check with one-way flag."""
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
project_check(project, "my-bucket", one_way=True)
cmd = mock_run.call_args[0][0]
assert "--one-way" in cmd
def test_project_check_no_local_path():
"""Test project check raises error when local_sync_path not configured."""
project = SyncProject(name="research", path="app/data/research")
with pytest.raises(RcloneError) as exc_info:
project_check(project, "my-bucket")
assert "no local_sync_path configured" in str(exc_info.value)
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_project_ls_success(mock_run):
"""Test successful project ls."""
mock_run.return_value = MagicMock(returncode=0, stdout="file1.md\nfile2.md\nsubdir/file3.md\n")
project = SyncProject(name="research", path="app/data/research")
files = project_ls(project, "my-bucket")
assert len(files) == 3
assert "file1.md" in files
assert "file2.md" in files
assert "subdir/file3.md" in files
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_project_ls_with_subpath(mock_run):
"""Test project ls with subdirectory."""
mock_run.return_value = MagicMock(returncode=0, stdout="")
project = SyncProject(name="research", path="/research") # Normalized path
project_ls(project, "my-bucket", path="subdir")
cmd = mock_run.call_args[0][0]
assert cmd[-1] == "basic-memory-cloud:my-bucket/research/subdir"
-82
View File
@@ -11,13 +11,11 @@ from basic_memory.file_utils import (
FileWriteError,
ParseError,
compute_checksum,
ensure_directory,
has_frontmatter,
parse_frontmatter,
remove_frontmatter,
sanitize_for_filename,
sanitize_for_folder,
update_frontmatter,
write_file_atomic,
)
@@ -51,15 +49,6 @@ async def test_compute_checksum_error():
await compute_checksum(object()) # pyright: ignore [reportArgumentType]
@pytest.mark.asyncio
async def test_ensure_directory(tmp_path: Path):
"""Test directory creation."""
test_dir = tmp_path / "test_dir"
await ensure_directory(test_dir)
assert test_dir.exists()
assert test_dir.is_dir()
@pytest.mark.asyncio
async def test_write_file_atomic(tmp_path: Path):
"""Test atomic file writing."""
@@ -188,77 +177,6 @@ title: Test""")
assert "Invalid frontmatter format" in str(exc.value)
@pytest.mark.asyncio
async def test_update_frontmatter(tmp_path: Path):
"""Test updating frontmatter in a file."""
test_file = tmp_path / "test.md"
# Test 1: Add frontmatter to file without any
content = "# Test Content\n\nSome content here"
test_file.write_text(content)
updates = {"title": "Test", "type": "note"}
checksum = await update_frontmatter(test_file, updates)
# Verify content
updated = test_file.read_text(encoding="utf-8")
assert "title: Test" in updated
assert "type: note" in updated
assert "Test Content" in updated
assert "Some content here" in updated
# Verify structure
fm = parse_frontmatter(updated)
assert fm == updates
assert remove_frontmatter(updated).strip() == content
# Test 2: Update existing frontmatter
updates = {"type": "doc", "tags": ["test"]}
new_checksum = await update_frontmatter(test_file, updates)
# Verify checksum changed
assert new_checksum != checksum
# Verify content
updated = test_file.read_text(encoding="utf-8")
fm = parse_frontmatter(updated)
assert fm == {"title": "Test", "type": "doc", "tags": ["test"]}
assert "Test Content" in updated
# Test 3: Update with empty dict shouldn't change anything
checksum_before = await compute_checksum(test_file.read_text(encoding="utf-8"))
new_checksum = await update_frontmatter(test_file, {})
assert new_checksum == checksum_before
# Test 4: Handle multi-line content properly
content = """# Heading
Some content
## Section
- Point 1
- Point 2
### Subsection
More content here"""
test_file.write_text(content)
await update_frontmatter(test_file, {"title": "Test"})
updated = test_file.read_text(encoding="utf-8")
assert remove_frontmatter(updated).strip() == content
@pytest.mark.asyncio
async def test_update_frontmatter_errors(tmp_path: Path):
"""Test error handling in update_frontmatter."""
# Test 1: Invalid file path
nonexistent = tmp_path / "nonexistent" / "test.md"
with pytest.raises(FileError):
await update_frontmatter(nonexistent, {"title": "Test"})
@pytest.mark.asyncio
def test_sanitize_for_filename_removes_invalid_characters():
# Test all invalid characters listed in the regex

Some files were not shown because too many files have changed in this diff Show More