Skip to content

Commit 46717d1

Browse files
committed
feat: project credential resolution for observer daemon + unified status
Observer daemon now resolves per-project CEMS credentials for sessions running in git worktrees (e.g., Codex). Previously all sessions went to the global CEMS instance regardless of project configuration. - CredentialResolver: walk-up → git worktree fallback → source_ref cache → global - seed_from_state: persist api_url in state files, restore cache across restarts - populate_session_metadata: scan first 10 JSONL lines for CWD (was only line 0) - cems update: restart observer daemon after package upgrade - cems status: merged status + health into unified command with daemon info - debug dashboard: query correct CEMS instance per-session for stored memories
1 parent 7caa6f2 commit 46717d1

File tree

10 files changed

+316
-73
lines changed

10 files changed

+316
-73
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cems"
3-
version = "0.11.3"
3+
version = "0.11.4"
44
description = "Continuous Evolving Memory System - Dual-layer memory with scheduled maintenance"
55
readme = "README.md"
66
requires-python = ">=3.11"

src/cems/cli.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from cems.commands.memory import update as update_memory
2020
from cems.commands.rule import rule
2121
from cems.commands.setup import setup
22-
from cems.commands.status import health, status
22+
from cems.commands.status import status
2323
from cems.commands.uninstall import uninstall
2424
from cems.commands.update import update_cmd
2525

@@ -75,7 +75,6 @@ def main(ctx: click.Context, verbose: bool, api_url: str | None, api_key: str |
7575

7676
# Register all commands
7777
main.add_command(status)
78-
main.add_command(health)
7978
main.add_command(add)
8079
main.add_command(search)
8180
main.add_command(list_memories, name="list")

src/cems/commands/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
from cems.commands.maintenance import maintenance
55
from cems.commands.memory import add, delete, list_memories, search, update
66
from cems.commands.rule import rule
7-
from cems.commands.status import health, status
7+
from cems.commands.status import status
88

99
__all__ = [
1010
"status",
11-
"health",
1211
"add",
1312
"search",
1413
"list_memories",

src/cems/commands/status.py

Lines changed: 72 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,94 @@
1-
"""Status and health commands for CEMS CLI."""
1+
"""Status command for CEMS CLI.
2+
3+
Shows a unified view of client, server, and observer daemon state.
4+
"""
5+
6+
import os
7+
from importlib.metadata import version as pkg_version
8+
from pathlib import Path
29

310
import click
411
from rich.table import Table
512

613
from cems.cli_utils import console, get_client, handle_error
714
from cems.client import CEMSClientError
815

16+
DAEMON_PID_FILE = Path.home() / ".cems" / "observer" / "daemon.pid"
17+
18+
19+
def _get_client_version() -> str:
20+
"""Get installed CEMS package version."""
21+
try:
22+
return pkg_version("cems")
23+
except Exception:
24+
return "unknown"
25+
26+
27+
def _get_daemon_info() -> dict:
28+
"""Get observer daemon status."""
29+
info: dict = {"status": "stopped", "pid": None}
30+
try:
31+
if DAEMON_PID_FILE.exists():
32+
pid = int(DAEMON_PID_FILE.read_text().strip())
33+
# Check if process is alive
34+
os.kill(pid, 0)
35+
info["status"] = "running"
36+
info["pid"] = pid
37+
except (ValueError, ProcessLookupError, PermissionError, OSError):
38+
pass
39+
return info
40+
941

1042
@click.command()
1143
@click.pass_context
1244
def status(ctx: click.Context) -> None:
13-
"""Show CEMS status and configuration."""
14-
try:
15-
client = get_client(ctx)
16-
data = client.status()
17-
18-
table = Table(title="CEMS Status")
19-
table.add_column("Setting", style="cyan")
20-
table.add_column("Value", style="green")
45+
"""Show CEMS status — client, server, and daemon."""
46+
client_version = _get_client_version()
2147

22-
table.add_row("Server", client.api_url)
23-
table.add_row("User ID", data.get("user_id", "?"))
24-
table.add_row("Team ID", data.get("team_id") or "(not set)")
25-
table.add_row("Status", data.get("status", "unknown"))
26-
table.add_row("Backend", data.get("backend", "?"))
27-
table.add_row("Vector Store", data.get("vector_store", "?"))
28-
table.add_row("Query Synthesis", str(data.get("query_synthesis", False)))
48+
# --- Client ---
49+
api_url = ctx.obj.get("api_url") or "(not configured)"
50+
console.print()
51+
console.print("[bold]CEMS Status[/bold]")
2952

30-
console.print(table)
53+
table = Table(show_header=False, box=None, padding=(0, 2))
54+
table.add_column("Key", style="dim")
55+
table.add_column("Value")
3156

32-
except CEMSClientError as e:
33-
handle_error(e)
57+
table.add_row("", "")
58+
table.add_row("[cyan bold]Client[/cyan bold]", "")
59+
table.add_row(" Version", client_version)
60+
table.add_row(" API URL", str(api_url))
3461

62+
# --- Server ---
63+
table.add_row("", "")
64+
table.add_row("[cyan bold]Server[/cyan bold]", "")
3565

36-
@click.command()
37-
@click.pass_context
38-
def health(ctx: click.Context) -> None:
39-
"""Check server health."""
4066
try:
4167
client = get_client(ctx)
68+
health = client.health()
69+
server_status = health.get("status", "unknown")
70+
server_version = health.get("version", "unknown")
71+
db_status = health.get("database", "unknown")
72+
73+
color = "green" if server_status == "healthy" else "red"
74+
table.add_row(" Status", f"[{color}]{server_status}[/{color}]")
75+
table.add_row(" Version", server_version)
76+
77+
db_color = "green" if db_status == "healthy" else "yellow"
78+
table.add_row(" Database", f"[{db_color}]{db_status}[/{db_color}]")
4279

43-
with console.status("Checking health..."):
44-
result = client.health()
80+
except CEMSClientError:
81+
table.add_row(" Status", "[red]unreachable[/red]")
4582

46-
status_text = result.get("status", "unknown")
47-
if status_text == "healthy":
48-
console.print(f"[green]Server: {status_text}[/green]")
49-
else:
50-
console.print(f"[yellow]Server: {status_text}[/yellow]")
83+
# --- Observer Daemon ---
84+
table.add_row("", "")
85+
table.add_row("[cyan bold]Observer Daemon[/cyan bold]", "")
5186

52-
console.print(f"Service: {result.get('service', '?')}")
53-
console.print(f"Mode: {result.get('mode', '?')}")
54-
console.print(f"Auth: {result.get('auth', '?')}")
55-
console.print(f"Database: {result.get('database', '?')}")
87+
daemon = _get_daemon_info()
88+
if daemon["status"] == "running":
89+
table.add_row(" Status", f"[green]running[/green] (PID {daemon['pid']})")
90+
else:
91+
table.add_row(" Status", "[yellow]stopped[/yellow]")
5692

57-
except CEMSClientError as e:
58-
handle_error(e)
93+
console.print(table)
94+
console.print()

src/cems/commands/update.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
"""
99

1010
import os
11+
import signal
1112
import shutil
1213
import subprocess
14+
import time
1315
from datetime import datetime, timezone
1416
from importlib.metadata import version as pkg_version
1517
from pathlib import Path
@@ -159,6 +161,31 @@ def _redeploy_hooks() -> None:
159161
console.print("[yellow]Hook re-deploy had errors (see above)[/yellow]")
160162

161163

164+
def _restart_daemon() -> None:
165+
"""Stop the observer daemon so it restarts with updated code.
166+
167+
Sends SIGTERM to the running daemon (if any). The next hook invocation
168+
will auto-respawn it via ensure_daemon_running().
169+
"""
170+
pid_file = Path.home() / ".cems" / "observer" / "daemon.pid"
171+
try:
172+
if not pid_file.exists():
173+
return
174+
pid = int(pid_file.read_text().strip())
175+
os.kill(pid, signal.SIGTERM)
176+
# Wait briefly for clean shutdown
177+
for _ in range(10):
178+
time.sleep(0.3)
179+
try:
180+
os.kill(pid, 0) # Check if still alive
181+
except ProcessLookupError:
182+
console.print(" Observer daemon stopped (will auto-restart)")
183+
return
184+
console.print("[yellow] Observer daemon did not stop in time — it will pick up changes on next restart[/yellow]")
185+
except (ValueError, ProcessLookupError, PermissionError, OSError):
186+
pass
187+
188+
162189
@click.command("update")
163190
@click.option("--hooks", "hooks_only", is_flag=True, help="Only re-deploy hooks/skills (skip package upgrade)")
164191
def update_cmd(hooks_only: bool) -> None:
@@ -204,5 +231,9 @@ def update_cmd(hooks_only: bool) -> None:
204231
else:
205232
console.print("[dim]Hooks up to date, skipping re-deploy[/dim]")
206233

234+
# Restart observer daemon so it picks up updated code
235+
if hooks_only or version_changed:
236+
_restart_daemon()
237+
207238
console.print()
208239
console.print("[bold green]Update complete![/bold green] Restart your IDE to pick up changes.")

src/cems/debug/indexer.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -316,22 +316,23 @@ def get_observer_stats(self) -> dict:
316316
def get_observer_session_memories(self, sid: str) -> list[dict]:
317317
"""Fetch stored memories for an observer session via CEMS API.
318318
319-
Uses credentials from ~/.cems/credentials to call the CEMS API's
320-
/api/memory/list endpoint with tag_prefix filter.
321-
Falls back to client-side filtering if server doesn't support tag_prefix.
319+
Uses the session's api_url (from state file) when available, otherwise
320+
falls back to global credentials. This ensures project-credential
321+
sessions query the correct CEMS instance.
322322
"""
323-
creds = _load_cems_credentials()
324-
if not creds["url"] or not creds["key"]:
323+
# Try to use the session's stored API URL + resolve matching key
324+
api_url, api_key = self._resolve_session_credentials(sid)
325+
if not api_url or not api_key:
325326
return []
326327

327328
short_id = sid[:12]
328329
tag_prefix = f"session:{short_id}"
329330
url = (
330-
f"{creds['url'].rstrip('/')}/api/memory/list"
331+
f"{api_url.rstrip('/')}/api/memory/list"
331332
f"?category=session-summary&tag_prefix={tag_prefix}&limit=50"
332333
)
333334
req = urllib.request.Request(url, headers={
334-
"Authorization": f"Bearer {creds['key']}",
335+
"Authorization": f"Bearer {api_key}",
335336
"User-Agent": "CEMS-Debug/1.0",
336337
})
337338
try:
@@ -348,6 +349,48 @@ def get_observer_session_memories(self, sid: str) -> list[dict]:
348349
logging.getLogger(__name__).debug(f"Failed to fetch memories for {sid}: {e}")
349350
return []
350351

352+
def _resolve_session_credentials(self, sid: str) -> tuple[str, str]:
353+
"""Resolve API URL and key for a session.
354+
355+
If the session state has an api_url that differs from global,
356+
walk up from the session's CWD to find the matching project key.
357+
Falls back to global credentials.
358+
"""
359+
from cems.shared.credentials import find_project_credentials, parse_credentials_file
360+
361+
state_file = OBSERVER_DIR / f"{sid}.json"
362+
session_api_url = ""
363+
session_cwd = ""
364+
if state_file.exists():
365+
try:
366+
data = json.loads(state_file.read_text())
367+
session_api_url = data.get("api_url", "")
368+
# Try to reconstruct CWD from source_ref isn't reliable,
369+
# but we stored api_url which is what matters
370+
except (json.JSONDecodeError, OSError):
371+
pass
372+
373+
creds = _load_cems_credentials()
374+
global_url = creds.get("url", "")
375+
global_key = creds.get("key", "")
376+
377+
# If session used a different API URL, find matching credentials
378+
if session_api_url and session_api_url.rstrip("/") != global_url.rstrip("/"):
379+
# Scan known project credential files for a matching URL
380+
for creds_file in Path.home().glob("Development/*/.cems/credentials"):
381+
try:
382+
pcreds = parse_credentials_file(str(creds_file))
383+
purl = pcreds.get("CEMS_API_URL", "")
384+
pkey = pcreds.get("CEMS_API_KEY", "")
385+
if purl.rstrip("/") == session_api_url.rstrip("/") and pkey:
386+
return purl, pkey
387+
except OSError:
388+
continue
389+
# Couldn't find matching key — fall through to global
390+
return session_api_url, global_key
391+
392+
return global_url, global_key
393+
351394
def get_conflicts(self) -> list[dict]:
352395
"""Fetch open memory conflicts from CEMS API."""
353396
creds = _load_cems_credentials()

0 commit comments

Comments
 (0)