Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
test-python:
name: Python Test & Lint
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4

Expand All @@ -47,7 +48,7 @@ jobs:
run: uv run ruff check .

- name: Run tests
run: uv run pytest -v --cov=src/kurt --cov-report=term-missing
run: uv run pytest -v -n auto --ignore=src/kurt/tools/e2e --ignore-glob="**/test_*e2e.py"

# Run frontend tests
test-frontend:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,6 @@ dev = [
"pytest>=8.4.2",
"pytest-asyncio>=0.24.0",
"pytest-cov>=4.1.0",
"pytest-xdist>=3.0.0",
"ruff>=0.1.0",
]
213 changes: 199 additions & 14 deletions src/kurt/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
3. branch_sync: Git branch matches Dolt branch
4. no_uncommitted_dolt: Dolt status is clean
5. remotes_configured: Both Git and Dolt have 'origin' remote
6. sql_server: Dolt SQL server reachable (server mode required)
7. no_stale_locks: No .git/kurt-hook.lock older than 30s
6. no_stale_dolt_locks: No stale .dolt/noms/LOCK files
7. stale_server_info: No stale .dolt/kurt-server.json with dead PIDs
8. sql_server: Dolt SQL server reachable (server mode required)
9. no_stale_locks: No .git/kurt-hook.lock older than 30s

SQL Runtime:
Kurt uses server mode exclusively for SQL operations. The dolt sql-server
is auto-started for local targets (localhost) if not running. Remote servers
must be started and accessible independently.
must be started and accessible independently. Each project gets its own
server on its own port to avoid conflicts.
"""

from __future__ import annotations
Expand Down Expand Up @@ -411,7 +414,7 @@ def check_sql_server(dolt_path: Path) -> CheckResult:
if server_running:
# For local servers, verify it's the correct project's server
if is_local:
info_file = dolt_path / "sql-server.info"
info_file = dolt_path / "kurt-server.json"
if info_file.exists():
try:
import json as json_mod
Expand Down Expand Up @@ -455,6 +458,109 @@ def check_sql_server(dolt_path: Path) -> CheckResult:
)


def check_no_stale_dolt_locks(dolt_path: Path) -> CheckResult:
"""Check for stale Dolt noms LOCK files.

These can occur when dolt sql-server is killed ungracefully (e.g., pkill).
A LOCK file is considered stale only if no server is running for this project.
"""
lock_file = dolt_path / "noms" / "LOCK"

if not lock_file.exists():
return CheckResult(
name="no_stale_dolt_locks",
status=CheckStatus.PASS,
message="No Dolt lock files present",
)

# Check if a server is running for this project
info_file = dolt_path / "kurt-server.json"
server_running = False
if info_file.exists():
try:
import json as json_mod

info = json_mod.loads(info_file.read_text())
pid = info.get("pid")
if pid:
try:
os.kill(pid, 0) # Check if process exists
server_running = True
except OSError:
pass
except Exception:
pass

if server_running:
return CheckResult(
name="no_stale_dolt_locks",
status=CheckStatus.PASS,
message="Dolt lock file present (server running)",
)

# No server running but lock file exists - it's stale
return CheckResult(
name="no_stale_dolt_locks",
status=CheckStatus.FAIL,
message="Stale Dolt lock file (no server running)",
details="Run 'kurt repair' to remove stale lock",
)


def check_stale_server_info(dolt_path: Path) -> CheckResult:
"""Check for stale kurt-server.json files with dead PIDs.

The kurt-server.json file tracks which project started a server.
If the PID is dead but the file exists, it's stale and can cause
connection issues.
"""
info_file = dolt_path / "kurt-server.json"

if not info_file.exists():
return CheckResult(
name="stale_server_info",
status=CheckStatus.PASS,
message="No server info file",
)

try:
import json as json_mod

info = json_mod.loads(info_file.read_text())
pid = info.get("pid")

if pid is None:
return CheckResult(
name="stale_server_info",
status=CheckStatus.PASS,
message="Server info file present (no PID)",
)

# Check if process is still running
try:
os.kill(pid, 0) # Signal 0 = check if process exists
return CheckResult(
name="stale_server_info",
status=CheckStatus.PASS,
message=f"Server info valid (PID {pid} running)",
)
except OSError:
# Process doesn't exist - stale info file
return CheckResult(
name="stale_server_info",
status=CheckStatus.WARN,
message=f"Stale server info (PID {pid} not running)",
details="Run 'kurt repair' to clean up",
)
except Exception as e:
return CheckResult(
name="stale_server_info",
status=CheckStatus.WARN,
message="Could not check server info file",
details=str(e),
)


def check_no_stale_locks(git_path: Path) -> CheckResult:
"""Check for stale kurt-hook.lock files."""
lock_dir = git_path / ".git" / "kurt-hook.lock"
Expand All @@ -463,7 +569,7 @@ def check_no_stale_locks(git_path: Path) -> CheckResult:
return CheckResult(
name="no_stale_locks",
status=CheckStatus.PASS,
message="No lock files present",
message="No Git hook lock files present",
)

# Check lock age
Expand Down Expand Up @@ -532,6 +638,8 @@ def run_doctor(git_path: Path, dolt_path: Path) -> DoctorReport:
checks.append(check_branch_sync(git_path, dolt_path))
checks.append(check_no_uncommitted_dolt(dolt_path))
checks.append(check_remotes_configured(git_path, dolt_path))
checks.append(check_no_stale_dolt_locks(dolt_path))
checks.append(check_stale_server_info(dolt_path))
checks.append(check_sql_server(dolt_path))

checks.append(check_no_stale_locks(git_path))
Expand All @@ -553,7 +661,9 @@ def run_doctor(git_path: Path, dolt_path: Path) -> DoctorReport:


@click.command(name="doctor")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON (deprecated: use global --json)")
@click.option(
"--json", "as_json", is_flag=True, help="Output as JSON (deprecated: use global --json)"
)
@click.pass_context
def doctor_cmd(ctx, as_json: bool):
"""Check project health and report issues.
Expand All @@ -566,8 +676,10 @@ def doctor_cmd(ctx, as_json: bool):
3. branch_sync: Git branch matches Dolt branch
4. no_uncommitted_dolt: Dolt status is clean
5. remotes_configured: Both Git and Dolt have 'origin' remote
6. sql_server: Dolt SQL server is reachable (server mode required)
7. no_stale_locks: No .git/kurt-hook.lock older than 30s
6. no_stale_dolt_locks: No stale .dolt/noms/LOCK files
7. stale_server_info: No stale .dolt/kurt-server.json with dead PIDs
8. sql_server: Dolt SQL server is reachable (server mode required)
9. no_stale_locks: No .git/kurt-hook.lock older than 30s

Exit codes:
0: All checks passed
Expand Down Expand Up @@ -708,10 +820,26 @@ def get_repair_actions(report: DoctorReport) -> list[RepairAction]:
actions.append(
RepairAction(
check_name="no_stale_locks",
description="Remove stale lock file",
description="Remove stale Git hook lock file",
action="remove_lock",
)
)
elif check.name == "no_stale_dolt_locks" and check.status == CheckStatus.FAIL:
actions.append(
RepairAction(
check_name="no_stale_dolt_locks",
description="Remove stale Dolt lock file",
action="remove_dolt_lock",
)
)
elif check.name == "stale_server_info" and check.status == CheckStatus.WARN:
actions.append(
RepairAction(
check_name="stale_server_info",
description="Clean up stale server info file",
action="clean_server_info",
)
)
elif check.name == "sql_server" and check.status == CheckStatus.FAIL:
# Use consistent server config parsing
host, _ = _parse_server_config()
Expand Down Expand Up @@ -785,7 +913,7 @@ def do_commit_dolt(dolt_path: Path) -> bool:


def do_remove_lock(git_path: Path) -> bool:
"""Remove stale lock file."""
"""Remove stale Git hook lock file."""
import shutil

lock_dir = git_path / ".git" / "kurt-hook.lock"
Expand All @@ -798,6 +926,51 @@ def do_remove_lock(git_path: Path) -> bool:
return True


def do_remove_dolt_lock(dolt_path: Path) -> bool:
"""Remove stale Dolt noms LOCK file.

This file can become stale when dolt sql-server is killed ungracefully.
"""
lock_file = dolt_path / "noms" / "LOCK"
if lock_file.exists():
try:
lock_file.unlink()
return True
except Exception:
return False
return True


def do_clean_server_info(dolt_path: Path) -> bool:
"""Clean up stale kurt-server.json file.

Removes the file if the PID it references is no longer running.
"""
info_file = dolt_path / "kurt-server.json"
if not info_file.exists():
return True

try:
import json as json_mod

info = json_mod.loads(info_file.read_text())
pid = info.get("pid")

# If no PID or PID is dead, remove the file
if pid is not None:
try:
os.kill(pid, 0)
# Process is running - don't remove
return False
except OSError:
pass # Process dead, continue to remove

info_file.unlink()
return True
except Exception:
return False


def do_start_server(dolt_path: Path) -> bool:
"""Start Dolt SQL server for local development.

Expand Down Expand Up @@ -834,13 +1007,14 @@ def do_start_server(dolt_path: Path) -> bool:

# Start the server
try:
subprocess.Popen(
proc = subprocess.Popen(
["dolt", "sql-server", "--port", str(port), "--host", "127.0.0.1"],
cwd=dolt_path.parent,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
server_pid = proc.pid

# Wait for server to be ready
import time
Expand All @@ -853,11 +1027,15 @@ def do_start_server(dolt_path: Path) -> bool:
sock.close()
if result == 0:
# Write server info file
info_file = dolt_path / "sql-server.info"
info_file = dolt_path / "kurt-server.json"
try:
import json as json_mod

info = {"path": str(dolt_path.parent.resolve()), "port": port}
info = {
"path": str(dolt_path.parent.resolve()),
"port": port,
"pid": server_pid,
}
info_file.write_text(json_mod.dumps(info))
except Exception:
pass
Expand Down Expand Up @@ -904,7 +1082,9 @@ def repair_cmd(dry_run: bool, yes: bool, check_name: str | None, force: bool):
- hooks_installed=fail: Reinstall Git hooks
- branch_sync=fail: Sync Dolt branch to match Git
- no_uncommitted_dolt=warn: Commit pending Dolt changes
- no_stale_locks=fail: Remove stale lock files
- no_stale_locks=fail: Remove stale Git hook lock files
- no_stale_dolt_locks=fail: Remove stale Dolt noms LOCK files
- stale_server_info=warn: Clean up stale server info files
- sql_server=fail: Start Dolt SQL server (local only)

SQL Server Repair:
Expand All @@ -918,6 +1098,7 @@ def repair_cmd(dry_run: bool, yes: bool, check_name: str | None, force: bool):
kurt repair --dry-run # Preview repairs
kurt repair --check=hooks_installed # Fix specific check
kurt repair --check=sql_server # Start local SQL server
kurt repair --check=no_stale_dolt_locks # Remove stale Dolt locks
kurt repair --yes # Skip confirmations
"""
try:
Expand Down Expand Up @@ -982,6 +1163,10 @@ def repair_cmd(dry_run: bool, yes: bool, check_name: str | None, force: bool):
success = do_commit_dolt(dolt_path)
elif action.action == "remove_lock":
success = do_remove_lock(git_path)
elif action.action == "remove_dolt_lock":
success = do_remove_dolt_lock(dolt_path)
elif action.action == "clean_server_info":
success = do_clean_server_info(dolt_path)
elif action.action == "start_server":
success = do_start_server(dolt_path)
elif action.action == "notify_remote_server":
Expand Down
8 changes: 5 additions & 3 deletions src/kurt/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
- Command aliases for LLM typo tolerance (e.g., doc -> docs)
"""


import click
from dotenv import load_dotenv

Expand Down Expand Up @@ -53,8 +52,10 @@ def _auto_migrate_schema():
dolt_path = Path.cwd() / ".dolt"
if dolt_path.exists():
from kurt.db.dolt import DoltDB, check_schema_exists, init_observability_schema
from kurt.observability.tracking import init_tracking

db = DoltDB(Path.cwd())
init_tracking(db) # Enable global tracking for track_event() calls
schema_status = check_schema_exists(db)

# Only initialize if any table is missing
Expand Down Expand Up @@ -206,8 +207,9 @@ def main(ctx, json_output: bool, quiet: bool):
ctx.obj["output"] = OutputContext(json_output, quiet)
ctx.obj["json_output"] = json_output # Backwards compat for existing commands

# Skip auto-migrate for init command (no DB yet)
if ctx.invoked_subcommand in ["init", "help"]:
# Skip auto-migrate for commands that don't need DB or use Dolt CLI directly
# doctor/repair use Dolt CLI commands which conflict with the auto-started server
if ctx.invoked_subcommand in ["init", "help", "doctor", "repair"]:
return

# Skip if no project initialized
Expand Down
Loading
Loading