diff --git a/src/basic_memory/indexing/accepted_note_mutation_runner.py b/src/basic_memory/indexing/accepted_note_mutation_runner.py index 75ec1fb77..84fe7b049 100644 --- a/src/basic_memory/indexing/accepted_note_mutation_runner.py +++ b/src/basic_memory/indexing/accepted_note_mutation_runner.py @@ -791,6 +791,7 @@ async def _run_accepted_note_move( actor_user_profile_id=request.actor.user_profile_id, actor_kind=request.actor.kind, actor_name=request.actor.name, + previous_file_path=existing_file_path, cleanup_after_write=persisted.previous_file_delete, fallback_source=request.source, ) diff --git a/src/basic_memory/runtime/job_payloads.py b/src/basic_memory/runtime/job_payloads.py index d8562acab..73519f56d 100644 --- a/src/basic_memory/runtime/job_payloads.py +++ b/src/basic_memory/runtime/job_payloads.py @@ -76,6 +76,7 @@ class RuntimeNoteMaterializationJobPayload(BaseModel): actor_kind: str | None = None actor_name: str | None = None source: str | None = None + previous_file_path: str | None = None cleanup_file_path: str | None = None cleanup_file_checksum: str | None = None @@ -125,6 +126,7 @@ def from_runtime_request(cls, request: RuntimeNoteMaterializationJobRequest) -> actor_kind=request.actor_kind, actor_name=request.actor_name, source=request.source, + previous_file_path=request.previous_file_path, cleanup_file_path=request.cleanup_file_path, cleanup_file_checksum=request.cleanup_file_checksum, ) @@ -140,6 +142,7 @@ def to_runtime_request(self) -> RuntimeNoteMaterializationJobRequest: actor_kind=self.actor_kind, actor_name=self.actor_name, source=self.source, + previous_file_path=self.previous_file_path, cleanup_file_path=self.cleanup_file_path, cleanup_file_checksum=self.cleanup_file_checksum, ) diff --git a/src/basic_memory/runtime/note_content.py b/src/basic_memory/runtime/note_content.py index 3efdd472e..bb63ca2aa 100644 --- a/src/basic_memory/runtime/note_content.py +++ b/src/basic_memory/runtime/note_content.py @@ -708,6 +708,7 @@ class RuntimePendingNoteMaterialization: actor_kind: RuntimeNoteActorKind | None = None actor_name: RuntimeNoteActorName | None = None source: RuntimeNoteChangeSource | None = None + previous_file_path: RuntimeFilePath | None = None cleanup_after_write: RuntimePendingNoteFileDelete | None = None @@ -720,6 +721,7 @@ def plan_pending_note_materialization( actor_user_profile_id: UUID | None = None, actor_kind: RuntimeNoteActorKind | None = None, actor_name: RuntimeNoteActorName | None = None, + previous_file_path: RuntimeFilePath | None = None, cleanup_after_write: RuntimePendingNoteFileDelete | None = None, ) -> RuntimePendingNoteMaterialization: """Build the queued materialization marker from accepted note_content state.""" @@ -733,6 +735,7 @@ def plan_pending_note_materialization( actor_kind=actor_kind, actor_name=actor_name, source=str(source) if source else None, + previous_file_path=previous_file_path, cleanup_after_write=cleanup_after_write, ) @@ -749,6 +752,7 @@ class RuntimeNoteMaterializationJobRequest: actor_kind: RuntimeNoteActorKind | None = None actor_name: RuntimeNoteActorName | None = None source: RuntimeNoteChangeSource | None = None + previous_file_path: RuntimeFilePath | None = None cleanup_file_path: RuntimeFilePath | None = None cleanup_file_checksum: RuntimeFileChecksum | None = None @@ -805,6 +809,7 @@ def plan_note_materialization_job_request( actor_kind=materialization.actor_kind, actor_name=materialization.actor_name, source=materialization.source, + previous_file_path=materialization.previous_file_path, cleanup_file_path=cleanup.file_path if cleanup is not None else None, cleanup_file_checksum=cleanup.file_checksum if cleanup is not None else None, ) @@ -864,6 +869,7 @@ def plan_accepted_note_materialization_change[PayloadT]( actor_user_profile_id: UUID | None = None, actor_kind: RuntimeNoteActorKind | None = None, actor_name: RuntimeNoteActorName | None = None, + previous_file_path: RuntimeFilePath | None = None, cleanup_after_write: RuntimePendingNoteFileDelete | None = None, ) -> RuntimeAcceptedNoteChange[PayloadT]: """Build an accepted-note response plus materialization follow-up marker.""" @@ -878,6 +884,7 @@ def plan_accepted_note_materialization_change[PayloadT]( actor_user_profile_id=actor_user_profile_id, actor_kind=actor_kind, actor_name=actor_name, + previous_file_path=previous_file_path, cleanup_after_write=cleanup_after_write, ), ) @@ -956,6 +963,7 @@ def plan_accepted_note_write_change( actor_user_profile_id: UUID | None = None, actor_kind: RuntimeNoteActorKind | None = None, actor_name: RuntimeNoteActorName | None = None, + previous_file_path: RuntimeFilePath | None = None, cleanup_after_write: RuntimePendingNoteFileDelete | None = None, ) -> RuntimeAcceptedNoteChange[RuntimeAcceptedNoteResponse]: """Build the accepted-note response plus the materialization follow-up marker.""" @@ -973,6 +981,7 @@ def plan_accepted_note_write_change( actor_user_profile_id=actor_user_profile_id, actor_kind=actor_kind, actor_name=actor_name, + previous_file_path=previous_file_path, cleanup_after_write=cleanup_after_write, ) diff --git a/tests/indexing/test_accepted_note_mutation_runner.py b/tests/indexing/test_accepted_note_mutation_runner.py index 10349b844..54efec042 100644 --- a/tests/indexing/test_accepted_note_mutation_runner.py +++ b/tests/indexing/test_accepted_note_mutation_runner.py @@ -545,6 +545,7 @@ async def test_run_accepted_note_create_persists_prepared_markdown() -> None: assert change.materialization.actor_user_profile_id == _ACTOR_ID assert change.materialization.actor_kind == "user" assert change.materialization.actor_name == "Ada" + assert change.materialization.previous_file_path is None @pytest.mark.asyncio @@ -597,6 +598,7 @@ async def test_run_accepted_note_update_replaces_existing_note_content() -> None assert change.payload.title == "Replacement" assert change.materialization is not None assert change.materialization.db_version == 2 + assert change.materialization.previous_file_path is None @pytest.mark.asyncio @@ -964,13 +966,17 @@ async def test_run_accepted_note_edit_applies_patch_against_db_content() -> None @pytest.mark.asyncio -async def test_run_accepted_note_move_uses_policy_and_carries_cleanup() -> None: +@pytest.mark.parametrize("file_checksum", ["file-checksum", None]) +async def test_run_accepted_note_move_carries_previous_path_and_materialized_cleanup( + file_checksum: str | None, +) -> None: session = _MutationSession() project = _project() prepared = _prepared_replacement() prepared_move = _prepared_move() entity = _entity(file_path="notes/accepted.md") note_content = _note_content(entity) + note_content.file_checksum = file_checksum project_repository = _ProjectRepository(project) entity_lookup_repository = _EntityLookupRepository(by_external_id=entity) note_content_lookup_repository = _NoteContentLookupRepository(note_content) @@ -1015,9 +1021,14 @@ async def test_run_accepted_note_move_uses_policy_and_carries_cleanup() -> None: assert entity.permalink == "archive/accepted" assert change.status_code == 200 assert change.materialization is not None - assert change.materialization.cleanup_after_write is not None - assert change.materialization.cleanup_after_write.file_path == "notes/accepted.md" - assert change.materialization.cleanup_after_write.file_checksum == "file-checksum" + assert change.materialization.previous_file_path == "notes/accepted.md" + cleanup = change.materialization.cleanup_after_write + if file_checksum is None: + assert cleanup is None + else: + assert cleanup is not None + assert cleanup.file_path == "notes/accepted.md" + assert cleanup.file_checksum == "file-checksum" @pytest.mark.asyncio diff --git a/tests/runtime/test_pending_note_materialization.py b/tests/runtime/test_pending_note_materialization.py index 6c7b25a99..94d8eabac 100644 --- a/tests/runtime/test_pending_note_materialization.py +++ b/tests/runtime/test_pending_note_materialization.py @@ -4,11 +4,13 @@ from basic_memory.runtime.note_content import ( RuntimeAcceptedNoteContentWritePlan, + RuntimeNoteMaterializationJobRequest, RuntimePendingNoteFileDelete, RuntimePendingNoteMaterialization, next_runtime_note_content_version, plan_accepted_note_content_write, plan_accepted_note_materialization_change, + plan_note_materialization_job_request, plan_pending_note_materialization, ) @@ -40,6 +42,7 @@ def test_plan_pending_note_materialization_uses_fallback_source_when_missing() - fallback_source="api", actor_kind="mcp_client", actor_name="Claude Code", + previous_file_path="notes/old.md", cleanup_after_write=cleanup, ) @@ -51,6 +54,7 @@ def test_plan_pending_note_materialization_uses_fallback_source_when_missing() - actor_kind="mcp_client", actor_name="Claude Code", source="api", + previous_file_path="notes/old.md", cleanup_after_write=cleanup, ) @@ -87,6 +91,37 @@ def test_plan_pending_note_materialization_prefers_note_source() -> None: assert materialization.source == "mcp" +def test_plan_note_materialization_job_request_preserves_previous_move_path() -> None: + cleanup = RuntimePendingNoteFileDelete( + project_id=7, + entity_id=42, + file_path="notes/old.md", + file_checksum="old-checksum", + ) + materialization = RuntimePendingNoteMaterialization( + project_id=7, + entity_id=42, + db_version=4, + db_checksum="db-checksum", + source="api", + previous_file_path="notes/old.md", + cleanup_after_write=cleanup, + ) + + assert plan_note_materialization_job_request( + materialization + ) == RuntimeNoteMaterializationJobRequest( + project_id=7, + entity_id=42, + db_version=4, + db_checksum="db-checksum", + source="api", + previous_file_path="notes/old.md", + cleanup_file_path="notes/old.md", + cleanup_file_checksum="old-checksum", + ) + + def test_next_runtime_note_content_version_starts_new_rows_at_one() -> None: assert next_runtime_note_content_version(None) == 1 diff --git a/tests/runtime/test_runtime_job_payloads.py b/tests/runtime/test_runtime_job_payloads.py index 875124e49..0b5918b5c 100644 --- a/tests/runtime/test_runtime_job_payloads.py +++ b/tests/runtime/test_runtime_job_payloads.py @@ -66,6 +66,7 @@ def test_runtime_note_materialization_job_payload_round_trips_runtime_request() actor_kind=NOTE_OBJECT_ACTOR_KIND_MCP_CLIENT, actor_name="Claude Code", source="mcp", + previous_file_path="notes/previous.md", cleanup_file_path="notes/old.md", cleanup_file_checksum="old-file-sum", )