1313import shutil
1414import sys
1515import time
16+ from functools import wraps
1617from pathlib import Path
1718from typing import Literal
1819
@@ -42,6 +43,7 @@ def filter(self, record: logging.LogRecord) -> bool:
4243
4344from openkb .config import DEFAULT_CONFIG , load_config , save_config , load_global_config , register_kb
4445from openkb .converter import _registry_path , convert_document
46+ from openkb .locks import atomic_write_json , atomic_write_text , kb_ingest_lock , kb_read_lock
4547from openkb .log import append_log
4648from openkb .schema import AGENTS_MD , INDEX_SEED , PAGE_CONTENT_DIRS
4749
@@ -260,6 +262,12 @@ def _clear_existing_skill_dir(kb_dir: Path, name: str) -> None:
260262
261263
262264def add_single_file (file_path : Path , kb_dir : Path ) -> Literal ["added" , "skipped" , "failed" ]:
265+ """Convert, index, and compile a single document under the KB mutation lock."""
266+ with kb_ingest_lock (kb_dir / ".openkb" ):
267+ return _add_single_file_locked (file_path , kb_dir )
268+
269+
270+ def _add_single_file_locked (file_path : Path , kb_dir : Path ) -> Literal ["added" , "skipped" , "failed" ]:
263271 """Convert, index, and compile a single document into the knowledge base.
264272
265273 Steps:
@@ -405,6 +413,23 @@ def cli(ctx, verbose, kb_dir_override):
405413 ctx .obj ["kb_dir_override" ] = None
406414
407415
416+ def _with_kb_lock (* , exclusive : bool ):
417+ """Wrap a Click command in the appropriate KB lock when a KB exists."""
418+ def decorator (fn ):
419+ @wraps (fn )
420+ def wrapper (ctx , * args , ** kwargs ):
421+ kb_dir = _find_kb_dir (ctx .obj .get ("kb_dir_override" ))
422+ if kb_dir is None :
423+ return fn (ctx , * args , ** kwargs )
424+ if exclusive :
425+ with kb_ingest_lock (kb_dir / ".openkb" ):
426+ return fn (ctx , * args , ** kwargs )
427+ with kb_read_lock (kb_dir / ".openkb" ):
428+ return fn (ctx , * args , ** kwargs )
429+ return wrapper
430+ return decorator
431+
432+
408433@cli .command ()
409434@click .argument ("path" , default = "." )
410435def use (path ):
@@ -554,9 +579,9 @@ def init(model, language):
554579 Path ("wiki/entities" ).mkdir (parents = True , exist_ok = True )
555580
556581 # Write wiki files
557- Path ("wiki/AGENTS.md" ). write_text ( AGENTS_MD , encoding = "utf-8" )
558- Path ("wiki/index.md" ). write_text ( INDEX_SEED , encoding = "utf-8" )
559- Path ("wiki/log.md" ). write_text ( "# Operations Log\n \n " , encoding = "utf-8 " )
582+ atomic_write_text ( Path ("wiki/AGENTS.md" ), AGENTS_MD )
583+ atomic_write_text ( Path ("wiki/index.md" ), INDEX_SEED )
584+ atomic_write_text ( Path ("wiki/log.md" ), "# Operations Log\n \n " )
560585
561586 # Create .openkb/ state directory
562587 openkb_dir .mkdir ()
@@ -566,7 +591,7 @@ def init(model, language):
566591 "pageindex_threshold" : DEFAULT_CONFIG ["pageindex_threshold" ],
567592 }
568593 save_config (openkb_dir / "config.yaml" , config )
569- (openkb_dir / "hashes.json" ). write_text ( json . dumps ({}), encoding = "utf-8" )
594+ atomic_write_json (openkb_dir / "hashes.json" , {} )
570595
571596 # Write API key to KB-local .env (0600) if the user provided one
572597 if api_key :
@@ -587,6 +612,7 @@ def init(model, language):
587612@cli .command ()
588613@click .argument ("path" )
589614@click .pass_context
615+ @_with_kb_lock (exclusive = True )
590616def add (ctx , path ):
591617 """Add a document or directory of documents at PATH to the knowledge base.
592618
@@ -797,6 +823,7 @@ def _resolve_doc_identifier(registry, identifier: str) -> list[tuple[str, dict]]
797823@click .option ("--yes" , "-y" , is_flag = True , default = False ,
798824 help = "Skip the confirmation prompt." )
799825@click .pass_context
826+ @_with_kb_lock (exclusive = True )
800827def remove (ctx , identifier , keep_raw , keep_empty , dry_run , yes ):
801828 """Remove a document from the knowledge base.
802829
@@ -1086,6 +1113,7 @@ def _refresh_schema(wiki_dir: Path) -> bool:
10861113 help = "Overwrite wiki/AGENTS.md with the bundled schema (backs up "
10871114 "the old one to AGENTS.md.bak) if it differs." )
10881115@click .pass_context
1116+ @_with_kb_lock (exclusive = True )
10891117def recompile (ctx , doc_name , all_docs , dry_run , yes , refresh_schema ):
10901118 """Re-run the current compile pipeline on already-indexed documents.
10911119
@@ -1389,40 +1417,42 @@ async def run_lint(kb_dir: Path) -> Path | None:
13891417
13901418 openkb_dir = kb_dir / ".openkb"
13911419
1392- # Skip lint entirely when the KB has no indexed documents
1393- hashes_file = openkb_dir / "hashes.json"
1394- if hashes_file .exists ():
1395- hashes = json .loads (hashes_file .read_text (encoding = "utf-8" ))
1396- else :
1397- hashes = {}
1398- if not hashes :
1399- click .echo ("Nothing to lint — no documents indexed yet. Run `openkb add` first." )
1400- return
1420+ with kb_read_lock (openkb_dir ):
1421+ # Skip lint entirely when the KB has no indexed documents
1422+ hashes_file = openkb_dir / "hashes.json"
1423+ if hashes_file .exists ():
1424+ hashes = json .loads (hashes_file .read_text (encoding = "utf-8" ))
1425+ else :
1426+ hashes = {}
1427+ if not hashes :
1428+ click .echo ("Nothing to lint — no documents indexed yet. Run `openkb add` first." )
1429+ return
14011430
1402- config = load_config (openkb_dir / "config.yaml" )
1403- _setup_llm_key (kb_dir )
1404- model : str = config .get ("model" , DEFAULT_CONFIG ["model" ])
1431+ config = load_config (openkb_dir / "config.yaml" )
1432+ _setup_llm_key (kb_dir )
1433+ model : str = config .get ("model" , DEFAULT_CONFIG ["model" ])
14051434
1406- click .echo ("Running structural lint..." )
1407- structural_report = run_structural_lint (kb_dir )
1408- click .echo (structural_report )
1435+ click .echo ("Running structural lint..." )
1436+ structural_report = run_structural_lint (kb_dir )
1437+ click .echo (structural_report )
14091438
1410- click .echo ("Running knowledge lint..." )
1411- try :
1412- knowledge_report = await run_knowledge_lint (kb_dir , model )
1413- except Exception as exc :
1414- knowledge_report = f"Knowledge lint failed: { exc } "
1415- click .echo (knowledge_report )
1439+ click .echo ("Running knowledge lint..." )
1440+ try :
1441+ knowledge_report = await run_knowledge_lint (kb_dir , model )
1442+ except Exception as exc :
1443+ knowledge_report = f"Knowledge lint failed: { exc } "
1444+ click .echo (knowledge_report )
14161445
14171446 # Write combined report
1418- reports_dir = kb_dir / "wiki" / "reports"
1419- reports_dir .mkdir (parents = True , exist_ok = True )
1420- import datetime
1421- timestamp = datetime .datetime .now ().strftime ("%Y%m%d_%H%M%S" )
1422- report_path = reports_dir / f"lint_{ timestamp } .md"
1423- report_content = f"# Lint Report — { timestamp } \n \n ## Structural\n \n { structural_report } \n \n ## Semantic\n \n { knowledge_report } \n "
1424- report_path .write_text (report_content , encoding = "utf-8" )
1425- append_log (kb_dir / "wiki" , "lint" , f"report → { report_path .name } " )
1447+ with kb_ingest_lock (openkb_dir ):
1448+ reports_dir = kb_dir / "wiki" / "reports"
1449+ reports_dir .mkdir (parents = True , exist_ok = True )
1450+ import datetime
1451+ timestamp = datetime .datetime .now ().strftime ("%Y%m%d_%H%M%S" )
1452+ report_path = reports_dir / f"lint_{ timestamp } .md"
1453+ report_content = f"# Lint Report — { timestamp } \n \n ## Structural\n \n { structural_report } \n \n ## Semantic\n \n { knowledge_report } \n "
1454+ report_path .write_text (report_content , encoding = "utf-8" )
1455+ append_log (kb_dir / "wiki" , "lint" , f"report → { report_path .name } " )
14261456 click .echo (f"\n Report written to { report_path } " )
14271457 return report_path
14281458
@@ -1440,7 +1470,8 @@ def lint(ctx, fix):
14401470 return
14411471 if fix :
14421472 from openkb .lint import fix_broken_links
1443- files_changed , ghosts = fix_broken_links (kb_dir / "wiki" )
1473+ with kb_ingest_lock (kb_dir / ".openkb" ):
1474+ files_changed , ghosts = fix_broken_links (kb_dir / "wiki" )
14441475 if files_changed :
14451476 click .echo (
14461477 f"Fixed { ghosts } wikilink(s) across { files_changed } file(s)."
@@ -1515,6 +1546,7 @@ def print_list(kb_dir: Path) -> None:
15151546
15161547@cli .command (name = "list" )
15171548@click .pass_context
1549+ @_with_kb_lock (exclusive = False )
15181550def list_cmd (ctx ):
15191551 """List all documents in the knowledge base."""
15201552 kb_dir = _find_kb_dir (ctx .obj .get ("kb_dir_override" ))
@@ -1585,6 +1617,7 @@ def print_status(kb_dir: Path) -> None:
15851617
15861618@cli .command ()
15871619@click .pass_context
1620+ @_with_kb_lock (exclusive = False )
15881621def status (ctx ):
15891622 """Show the current status of the knowledge base.
15901623
0 commit comments