|
10 | 10 | - Simplified caching strategies |
11 | 11 | """ |
12 | 12 |
|
| 13 | +import os |
| 14 | +import pathlib |
| 15 | + |
13 | 16 | from fastapi import APIRouter, HTTPException, Response, Path |
14 | 17 | from loguru import logger |
15 | 18 |
|
16 | 19 | import logfire |
| 20 | +from basic_memory.ignore_utils import ( |
| 21 | + IGNORED_PATH_REJECTION_DETAIL, |
| 22 | + load_gitignore_patterns, |
| 23 | + should_ignore_path, |
| 24 | +) |
17 | 25 | from basic_memory.deps import ( |
18 | 26 | EntityServiceV2ExternalDep, |
19 | 27 | SearchServiceV2ExternalDep, |
|
24 | 32 | EntityRepositoryV2ExternalDep, |
25 | 33 | RelationRepositoryV2ExternalDep, |
26 | 34 | ProjectExternalIdPathDep, |
| 35 | + SyncServiceV2ExternalDep, |
27 | 36 | TaskSchedulerDep, |
28 | 37 | ) |
29 | 38 | from basic_memory.schemas import DeleteEntitiesResponse |
|
40 | 49 | MoveDirectoryRequestV2, |
41 | 50 | DeleteDirectoryRequestV2, |
42 | 51 | OrphanEntitiesResponse, |
| 52 | + SyncFileRequest, |
43 | 53 | ) |
44 | 54 | from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult |
| 55 | +from basic_memory.utils import validate_project_path |
45 | 56 |
|
46 | 57 | router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"]) |
47 | 58 |
|
@@ -236,6 +247,187 @@ async def resolve_identifier( |
236 | 247 | return result |
237 | 248 |
|
238 | 249 |
|
| 250 | +## Single-file sync endpoint |
| 251 | + |
| 252 | + |
| 253 | +def _canonical_file_path(home: pathlib.Path, segments: list[str]) -> str | None: |
| 254 | + """Resolve the actual on-disk casing of a file path under the project home. |
| 255 | +
|
| 256 | + Trigger: case-insensitive filesystems (macOS/Windows) pass existence checks for |
| 257 | + wrong-cased paths like 'notes/Disk-Note.md' when the file is 'notes/disk-note.md'. |
| 258 | + Why: indexing the caller-supplied casing misses the existing DB row keyed by the |
| 259 | + on-disk path and inserts a duplicate entity under the wrong-cased path. |
| 260 | + Outcome: each segment is matched against real directory entries — exact name first |
| 261 | + (so distinct case-variant files on case-sensitive filesystems stay distinct), |
| 262 | + then a unique case-insensitive match. Returns None when any segment cannot be |
| 263 | + matched to exactly one entry, including missing files. Traversal stops at the |
| 264 | + project boundary: a directory whose resolved path escapes the project home is |
| 265 | + never scanned. |
| 266 | + """ |
| 267 | + resolved_home = home.resolve() |
| 268 | + current = home |
| 269 | + canonical_segments: list[str] = [] |
| 270 | + for segment in segments: |
| 271 | + # Trigger: a previously matched segment may be a symlink whose target lies |
| 272 | + # outside the project root (e.g. wrong-cased 'LINK' matched the on-disk |
| 273 | + # 'link' -> /tmp/outside on a case-sensitive filesystem). |
| 274 | + # Why: os.scandir follows symlinked directories, so continuing would read |
| 275 | + # directory contents outside the project boundary even though the |
| 276 | + # post-canonicalization containment check rejects the request later. |
| 277 | + # Outcome: bail before scanning the moment resolution escapes the home. |
| 278 | + if not current.resolve().is_relative_to(resolved_home): |
| 279 | + return None |
| 280 | + try: |
| 281 | + with os.scandir(current) as entries_iter: |
| 282 | + entries = [entry.name for entry in entries_iter] |
| 283 | + except OSError: |
| 284 | + # A parent segment resolved to a non-directory (or vanished): no canonical |
| 285 | + # path exists for the remaining segments. |
| 286 | + return None |
| 287 | + if segment in entries: |
| 288 | + matched = segment |
| 289 | + else: |
| 290 | + matches = [entry for entry in entries if entry.lower() == segment.lower()] |
| 291 | + if len(matches) != 1: |
| 292 | + return None |
| 293 | + matched = matches[0] |
| 294 | + canonical_segments.append(matched) |
| 295 | + current = current / matched |
| 296 | + return "/".join(canonical_segments) |
| 297 | + |
| 298 | + |
| 299 | +@router.post("/sync-file", response_model=EntityResponseV2) |
| 300 | +async def sync_file( |
| 301 | + data: SyncFileRequest, |
| 302 | + project_id: ProjectExternalIdPathDep, |
| 303 | + sync_service: SyncServiceV2ExternalDep, |
| 304 | + project_config: ProjectConfigV2ExternalDep, |
| 305 | + search_service: SearchServiceV2ExternalDep, |
| 306 | + app_config: AppConfigDep, |
| 307 | +) -> EntityResponseV2: |
| 308 | + """Index a single markdown file that exists on disk but is not indexed yet. |
| 309 | +
|
| 310 | + Recovery path for files written directly to disk before the watcher indexed |
| 311 | + them (#581): callers such as edit_note can index the exact file and retry |
| 312 | + identifier resolution without running a full project sync. |
| 313 | +
|
| 314 | + Args: |
| 315 | + data: Request containing the markdown file path relative to project root |
| 316 | +
|
| 317 | + Returns: |
| 318 | + The indexed entity |
| 319 | +
|
| 320 | + Raises: |
| 321 | + HTTPException: 400 if the path escapes the project root, contains |
| 322 | + non-normalized segments, matches the project ignore rules, or is |
| 323 | + not markdown, 404 if the file does not exist on disk |
| 324 | + """ |
| 325 | + with logfire.span( |
| 326 | + "api.request.knowledge.sync_file", |
| 327 | + entrypoint="api", |
| 328 | + domain="knowledge", |
| 329 | + action="sync_file", |
| 330 | + ): |
| 331 | + logger.info(f"API v2 request: sync_file file_path='{data.file_path}'") |
| 332 | + |
| 333 | + if not validate_project_path(data.file_path, project_config.home): |
| 334 | + raise HTTPException( |
| 335 | + status_code=400, |
| 336 | + detail=f"File path '{data.file_path}' is not allowed - " |
| 337 | + "paths must stay within project boundaries", |
| 338 | + ) |
| 339 | + |
| 340 | + # Trigger: segments like './' or '//' survive the traversal check above |
| 341 | + # Why: a non-normalized path would index under a non-canonical DB key |
| 342 | + # Outcome: reject fail-fast instead of guessing the canonical form |
| 343 | + segments = data.file_path.replace("\\", "/").split("/") |
| 344 | + if any(segment in ("", ".") for segment in segments): |
| 345 | + raise HTTPException( |
| 346 | + status_code=400, |
| 347 | + detail=f"File path '{data.file_path}' is not normalized - " |
| 348 | + "segments like './' or '//' are not allowed", |
| 349 | + ) |
| 350 | + |
| 351 | + # Canonicalize to the actual on-disk casing so the DB lookup below hits the |
| 352 | + # row keyed by the real path instead of inserting a wrong-cased duplicate. |
| 353 | + file_path = _canonical_file_path(project_config.home, segments) |
| 354 | + if file_path is None: |
| 355 | + raise HTTPException( |
| 356 | + status_code=404, detail=f"File not found on disk: '{data.file_path}'" |
| 357 | + ) |
| 358 | + # Trigger: canonicalization rewrote a segment to its on-disk form, and that |
| 359 | + # segment may be a symlink. The pre-check above validated the ORIGINAL |
| 360 | + # request path — on a case-sensitive filesystem 'LINK/secret.md' does not |
| 361 | + # exist, so resolve() cannot follow the real 'link' symlink and the check |
| 362 | + # passes even when 'link' points outside the project root. |
| 363 | + # Why: indexing through an escaping symlink would read and index content |
| 364 | + # outside the project boundary — and even an is_file() existence probe on |
| 365 | + # the joined path would follow the symlink and stat its target, so |
| 366 | + # containment must hold BEFORE any filesystem probe that follows symlinks. |
| 367 | + # Path.resolve() only walks symlink names (readlink); it never opens or |
| 368 | + # stats the final target, so it is safe to run pre-containment. |
| 369 | + # Outcome: the canonical path is re-validated and the fully-resolved absolute |
| 370 | + # target must stay inside the resolved project home; escapes get a 400 |
| 371 | + # before the file-existence probe below ever touches the target. |
| 372 | + resolved_target = (project_config.home / file_path).resolve() |
| 373 | + if not validate_project_path(file_path, project_config.home) or not ( |
| 374 | + resolved_target.is_relative_to(project_config.home.resolve()) |
| 375 | + ): |
| 376 | + raise HTTPException( |
| 377 | + status_code=400, |
| 378 | + detail=f"File path '{data.file_path}' is not allowed - " |
| 379 | + "paths must stay within project boundaries", |
| 380 | + ) |
| 381 | + # Containment holds, so probing the resolved target cannot leave the project. |
| 382 | + if not resolved_target.is_file(): |
| 383 | + raise HTTPException( |
| 384 | + status_code=404, detail=f"File not found on disk: '{data.file_path}'" |
| 385 | + ) |
| 386 | + # Trigger: the canonical path matches the .bmignore / project .gitignore rules |
| 387 | + # Why: scan and watch flows filter ignored files before they ever reach the |
| 388 | + # indexer; indexing one here would bypass the ignored-file contract and |
| 389 | + # make hidden or gitignored content searchable |
| 390 | + # Outcome: the same should_ignore_path() rules apply to single-file sync |
| 391 | + ignore_patterns = load_gitignore_patterns(project_config.home) |
| 392 | + if should_ignore_path( |
| 393 | + project_config.home / file_path, project_config.home, ignore_patterns |
| 394 | + ): |
| 395 | + raise HTTPException( |
| 396 | + status_code=400, |
| 397 | + detail=f"File path '{data.file_path}' {IGNORED_PATH_REJECTION_DETAIL} " |
| 398 | + "and cannot be indexed", |
| 399 | + ) |
| 400 | + if not sync_service.file_service.is_markdown(file_path): |
| 401 | + raise HTTPException( |
| 402 | + status_code=400, |
| 403 | + detail=f"Only markdown files can be indexed: '{data.file_path}'", |
| 404 | + ) |
| 405 | + |
| 406 | + # Trigger: the file may already have a DB row (e.g. modified on disk after indexing) |
| 407 | + # Why: the indexer needs to know whether to insert or update the entity |
| 408 | + # Outcome: new is computed from the database instead of assumed by the caller |
| 409 | + existing = await sync_service.entity_repository.get_by_file_path(file_path) |
| 410 | + synced = await sync_service.sync_one_markdown_file( |
| 411 | + file_path, new=existing is None, index_search=True |
| 412 | + ) |
| 413 | + |
| 414 | + # Trigger: semantic search is enabled and the entity index was just refreshed |
| 415 | + # Why: the project sync flow awaits sync_entity_vectors_batch() inline after |
| 416 | + # indexing changed files (SyncService.sync); without the single-entity |
| 417 | + # equivalent, a note recovered via sync-file stays missing or stale in |
| 418 | + # semantic search until a later edit or full project sync |
| 419 | + # Outcome: vectors refresh synchronously before the response returns, |
| 420 | + # mirroring the sync flow instead of the out-of-band scheduler |
| 421 | + if app_config.semantic_search_enabled: |
| 422 | + await search_service.sync_entity_vectors_batch([synced.entity.id]) |
| 423 | + |
| 424 | + result = EntityResponseV2.model_validate(synced.entity) |
| 425 | + logger.info( |
| 426 | + f"API v2 response: sync_file file_path='{file_path}' external_id={result.external_id}" |
| 427 | + ) |
| 428 | + return result |
| 429 | + |
| 430 | + |
239 | 431 | ## Read endpoints |
240 | 432 |
|
241 | 433 |
|
|
0 commit comments