Skip to content

Commit f1b19f1

Browse files
Implement issue #94b: codehive code connects to backend
CLI probes backend health, creates/resumes project+session via API. Two CodeApp modes: backend (POST messages, stream via WS) and local (NativeEngine fallback). --session and --new flags. Tests added.
1 parent 0385650 commit f1b19f1

8 files changed

Lines changed: 856 additions & 18 deletions

backend/codehive/cli.py

Lines changed: 167 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,19 @@ def main() -> None:
466466
default=False,
467467
help="Skip all tool confirmation prompts",
468468
)
469+
# Mutually exclusive session flags
470+
code_session_group = code_parser.add_mutually_exclusive_group()
471+
code_session_group.add_argument(
472+
"--session",
473+
default=None,
474+
help="Connect to a specific existing session (UUID)",
475+
)
476+
code_session_group.add_argument(
477+
"--new",
478+
action="store_true",
479+
default=False,
480+
help="Always create a new session (don't resume the latest)",
481+
)
469482

470483
# providers subcommand group
471484
providers_parser = subparsers.add_parser("providers", help="Manage LLM providers")
@@ -640,8 +653,113 @@ def _resolve_provider(args: argparse.Namespace) -> tuple[str, str, str]:
640653
return api_key, base_url, model
641654

642655

656+
def _probe_backend(backend_url: str) -> bool:
657+
"""Probe GET /api/system/health on the backend. Returns True if reachable and 200."""
658+
try:
659+
resp = httpx.get(f"{backend_url}/api/system/health", timeout=3.0)
660+
return resp.status_code == 200
661+
except (httpx.ConnectError, httpx.TimeoutException, httpx.ConnectTimeout):
662+
return False
663+
except Exception:
664+
return False
665+
666+
667+
def _resolve_project_and_session(
668+
backend_url: str,
669+
project_dir: str,
670+
session_flag: str | None,
671+
new_flag: bool,
672+
) -> tuple[str, str]:
673+
"""Resolve project_id and session_id from the backend.
674+
675+
Returns (project_id, session_id) as strings.
676+
Raises SystemExit on errors.
677+
"""
678+
import uuid as _uuid
679+
680+
client = httpx.Client(base_url=backend_url, timeout=30.0)
681+
682+
# Get or create project by path
683+
try:
684+
resp = client.post("/api/projects/by-path", json={"path": project_dir})
685+
except httpx.ConnectError:
686+
print("Backend not available, starting local-only session", file=sys.stderr)
687+
raise SystemExit(None)
688+
689+
if resp.status_code not in (200, 201):
690+
print(
691+
f"Warning: Failed to resolve project (HTTP {resp.status_code}), "
692+
"starting local-only session",
693+
file=sys.stderr,
694+
)
695+
raise SystemExit(None)
696+
697+
project = resp.json()
698+
project_id = project["id"]
699+
700+
# Session resolution
701+
if session_flag:
702+
# Validate it looks like a UUID
703+
try:
704+
_uuid.UUID(session_flag)
705+
except ValueError:
706+
print(f"Error: Invalid session UUID: {session_flag}", file=sys.stderr)
707+
sys.exit(1)
708+
session_id = session_flag
709+
elif new_flag:
710+
resp = client.post(
711+
f"/api/projects/{project_id}/sessions",
712+
json={"name": "code-session", "engine": "native", "mode": "execution"},
713+
)
714+
if resp.status_code not in (200, 201):
715+
print(
716+
f"Warning: Failed to create session (HTTP {resp.status_code}), "
717+
"starting local-only session",
718+
file=sys.stderr,
719+
)
720+
raise SystemExit(None)
721+
session_id = resp.json()["id"]
722+
else:
723+
# List sessions, pick most recent
724+
resp = client.get(f"/api/projects/{project_id}/sessions")
725+
if resp.status_code == 200:
726+
sessions = resp.json()
727+
if sessions:
728+
# Pick the most recent by created_at
729+
most_recent = max(sessions, key=lambda s: s.get("created_at", ""))
730+
session_id = most_recent["id"]
731+
else:
732+
# No sessions exist, create one
733+
resp = client.post(
734+
f"/api/projects/{project_id}/sessions",
735+
json={
736+
"name": "code-session",
737+
"engine": "native",
738+
"mode": "execution",
739+
},
740+
)
741+
if resp.status_code not in (200, 201):
742+
print(
743+
f"Warning: Failed to create session (HTTP {resp.status_code}), "
744+
"starting local-only session",
745+
file=sys.stderr,
746+
)
747+
raise SystemExit(None)
748+
session_id = resp.json()["id"]
749+
else:
750+
print(
751+
f"Warning: Failed to list sessions (HTTP {resp.status_code}), "
752+
"starting local-only session",
753+
file=sys.stderr,
754+
)
755+
raise SystemExit(None)
756+
757+
client.close()
758+
return project_id, session_id
759+
760+
643761
def _code(args: argparse.Namespace) -> None:
644-
import os
762+
import uuid as _uuid
645763

646764
from codehive.clients.terminal.code_app import CodeApp
647765

@@ -650,23 +768,59 @@ def _code(args: argparse.Namespace) -> None:
650768
print(f"Error: {project_dir} is not a directory", file=sys.stderr)
651769
sys.exit(1)
652770

653-
api_key, base_url, model = _resolve_provider(args)
771+
backend_url = _get_base_url(args)
772+
session_flag = getattr(args, "session", None)
773+
new_flag = getattr(args, "new", False)
654774

655-
if not api_key:
775+
# Probe backend
776+
backend_available = _probe_backend(backend_url)
777+
778+
if not backend_available:
656779
print(
657-
"Error: No API key found. Set CODEHIVE_ANTHROPIC_API_KEY or "
658-
"ANTHROPIC_API_KEY environment variable.",
780+
"Backend not available, starting local-only session",
659781
file=sys.stderr,
660782
)
661-
sys.exit(1)
662783

663-
app = CodeApp(
664-
project_dir=project_dir,
665-
model=model,
666-
api_key=api_key,
667-
base_url=base_url,
668-
auto_approve=getattr(args, "auto_approve", False),
669-
)
784+
if backend_available:
785+
try:
786+
project_id, session_id = _resolve_project_and_session(
787+
backend_url, project_dir, session_flag, new_flag
788+
)
789+
except SystemExit:
790+
# Fall back to local mode
791+
backend_available = False
792+
project_id = None
793+
session_id = None
794+
else:
795+
project_id_uuid = _uuid.UUID(project_id)
796+
session_id_uuid = _uuid.UUID(session_id)
797+
798+
if backend_available:
799+
app = CodeApp(
800+
project_dir=project_dir,
801+
auto_approve=getattr(args, "auto_approve", False),
802+
backend_url=backend_url,
803+
project_id=project_id_uuid, # type: ignore[possibly-undefined]
804+
session_id=session_id_uuid, # type: ignore[possibly-undefined]
805+
)
806+
else:
807+
api_key, base_url, model = _resolve_provider(args)
808+
809+
if not api_key:
810+
print(
811+
"Error: No API key found. Set CODEHIVE_ANTHROPIC_API_KEY or "
812+
"ANTHROPIC_API_KEY environment variable.",
813+
file=sys.stderr,
814+
)
815+
sys.exit(1)
816+
817+
app = CodeApp(
818+
project_dir=project_dir,
819+
model=model,
820+
api_key=api_key,
821+
base_url=base_url,
822+
auto_approve=getattr(args, "auto_approve", False),
823+
)
670824
app.run()
671825

672826

backend/codehive/clients/terminal/code_app.py

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ def __init__(
199199
api_key: str = "",
200200
base_url: str = "",
201201
auto_approve: bool = False,
202+
backend_url: str | None = None,
203+
session_id: uuid.UUID | None = None,
204+
project_id: uuid.UUID | None = None,
202205
**kwargs: object,
203206
) -> None:
204207
super().__init__(**kwargs) # type: ignore[arg-type]
@@ -210,13 +213,15 @@ def __init__(
210213
self._always_approved: set[str] = set()
211214
self._approval_event: asyncio.Event = asyncio.Event()
212215
self._approval_result: str = ""
213-
self._session_id = uuid.uuid4()
216+
self._session_id = session_id or uuid.uuid4()
214217
self._engine: Any = None
215218
self._busy = False
216219
self._streaming_widget: _AssistantMarkdown | None = None
217220
self._streaming_buffer: str = ""
218221
self._user_scrolled_up = False
219222
self._awaiting_approval = False
223+
self._backend_url: str | None = backend_url
224+
self._project_id: uuid.UUID | None = project_id
220225

221226
def compose(self) -> ComposeResult:
222227
yield Header(show_clock=True)
@@ -232,6 +237,11 @@ async def on_mount(self) -> None:
232237
self.query_one("#code-input", _ChatInput).focus()
233238

234239
async def _init_engine(self) -> None:
240+
if self._backend_url is not None:
241+
# Backend mode: no local engine needed
242+
self._engine = None
243+
return
244+
235245
from codehive.engine.native import NativeEngine
236246
from codehive.execution.diff import DiffService
237247
from codehive.execution.file_ops import FileOps
@@ -432,13 +442,71 @@ def action_clear_chat(self) -> None:
432442
async def action_new_session(self) -> None:
433443
"""Start a new session -- reset engine state and clear the UI."""
434444
self.action_clear_chat()
435-
self._session_id = uuid.uuid4()
436445
self._always_approved = set()
437-
if self._engine is not None:
438-
await self._engine.create_session(self._session_id)
446+
447+
if self._backend_url is not None and self._project_id is not None:
448+
# Backend mode: create a new session via the API
449+
import httpx as _httpx
450+
451+
try:
452+
async with _httpx.AsyncClient(base_url=self._backend_url, timeout=30.0) as client:
453+
resp = await client.post(
454+
f"/api/projects/{self._project_id}/sessions",
455+
json={
456+
"name": "code-session",
457+
"engine": "native",
458+
"mode": "execution",
459+
},
460+
)
461+
if resp.status_code in (200, 201):
462+
self._session_id = uuid.UUID(resp.json()["id"])
463+
else:
464+
self._append_system(
465+
f"Failed to create new session (HTTP {resp.status_code})"
466+
)
467+
return
468+
except Exception as exc:
469+
self._append_system(f"Error creating session: {exc}")
470+
return
471+
else:
472+
self._session_id = uuid.uuid4()
473+
if self._engine is not None:
474+
await self._engine.create_session(self._session_id)
475+
439476
self._append_system(f"New session started in {self._project_dir}")
440477
self._set_status(f"[dim]project: {self._project_dir}[/dim]")
441478

479+
# ---- Backend message sending ------------------------------------------
480+
481+
async def _send_backend_message(self, message: str) -> Any:
482+
"""Send a message to the backend API and yield events from the response."""
483+
import httpx as _httpx
484+
485+
async with _httpx.AsyncClient(
486+
base_url=self._backend_url, # type: ignore[arg-type]
487+
timeout=300.0,
488+
) as client:
489+
resp = await client.post(
490+
f"/api/sessions/{self._session_id}/messages",
491+
json={"content": message},
492+
)
493+
if resp.status_code != 200:
494+
detail = ""
495+
try:
496+
detail = resp.json().get("detail", resp.text)
497+
except Exception:
498+
detail = resp.text
499+
yield {
500+
"type": "message.created",
501+
"role": "assistant",
502+
"content": f"Error from backend (HTTP {resp.status_code}): {detail}",
503+
}
504+
return
505+
506+
events = resp.json()
507+
for event in events:
508+
yield event
509+
442510
# ---- Event handling ---------------------------------------------------
443511

444512
async def on__chat_input_submitted(self, event: _ChatInput.Submitted) -> None:
@@ -477,7 +545,11 @@ async def _run_agent(self, message: str) -> None:
477545
t_start = time.monotonic()
478546
received_deltas = False
479547
try:
480-
async for event in self._engine.send_message(self._session_id, message):
548+
if self._backend_url is not None:
549+
event_iter = self._send_backend_message(message)
550+
else:
551+
event_iter = self._engine.send_message(self._session_id, message)
552+
async for event in event_iter:
481553
etype = event.get("type", "")
482554

483555
if etype == "message.delta" and event.get("role") == "assistant":

0 commit comments

Comments
 (0)