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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ classifiers = [

dependencies = [
"httpx",
"psutil",
]

[build-system]
Expand All @@ -43,6 +44,7 @@ dev = [
"pytest-timeout",
"ruff",
"mypy",
"types-psutil",
"build",
"twine",
"hatchling",
Expand Down
4 changes: 2 additions & 2 deletions src/opencode_runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
from .session import OpenCodeSession

__all__ = [
"OpenCodeRuntime",
"OpenCodeSession",
"OpenCodeEvent",
"OpenCodeResponse",
"OpenCodeRuntime",
"OpenCodeSession",
]
113 changes: 38 additions & 75 deletions src/opencode_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from datetime import datetime, timezone
from pathlib import Path

from .server import DisplayStatus, ServerManager, _compute_runtime_key
from .server import RuntimeStatus, ServerManager, _compute_runtime_key

# ---------------------------------------------------------------------------
# ANSI
Expand Down Expand Up @@ -59,45 +59,31 @@ def _home(path: str) -> str:
return path


def _uptime(started_at: str, alive: bool) -> str:
try:
mins = max(
0,
int(
(datetime.now(timezone.utc) - datetime.fromisoformat(started_at)).total_seconds()
// 60
),
)
except Exception:
return "?"
return f"Up {mins}m" if alive else f"Dead {mins}m"


def _row(label: str, value: str) -> None:
print(f" {_cyan(f'{label:<9}')} {value}")


_STATUS_ICONS: dict[DisplayStatus, str] = {
DisplayStatus.RUNNING: "●",
DisplayStatus.STARTING: "◐",
DisplayStatus.UNHEALTHY: "",
DisplayStatus.STALE: "",
DisplayStatus.FAILED: "",
# None means "claimed but not yet spawned" (ServerStatus.status while pid is None).
_STATUS_ICONS: dict[RuntimeStatus | None, str] = {
None: "◐",
RuntimeStatus.RUNNING: "",
RuntimeStatus.UNHEALTHY: "",
RuntimeStatus.STALE: "",
}
_STATUS_COLORS = {
DisplayStatus.RUNNING: _green,
DisplayStatus.STARTING: _yellow,
DisplayStatus.UNHEALTHY: _red,
DisplayStatus.STALE: _dim,
DisplayStatus.FAILED: _red,
None: _yellow,
RuntimeStatus.RUNNING: _green,
RuntimeStatus.UNHEALTHY: _red,
RuntimeStatus.STALE: _dim,
}


def _status_display(status: DisplayStatus) -> str:
"""Render a ServerStatus.display value as a coloured icon + label."""
def _status_display(status: RuntimeStatus | None) -> str:
"""Render a ServerStatus.status value as a coloured icon + label."""
icon = _STATUS_ICONS.get(status, "?")
color = _STATUS_COLORS.get(status, _dim)
return color(f"{icon} {status.value}")
label = status.value if status is not None else "starting"
return color(f"{icon} {label}")


# ---------------------------------------------------------------------------
Expand All @@ -121,12 +107,11 @@ async def _serve(args: argparse.Namespace) -> None:
manager = ServerManager()

existing = manager.find(key)
if existing is not None:
if manager.is_alive(key):
sys.exit(
_yellow(f"● Server already running id={existing.key} pid={existing.pid}\n")
+ _dim(f" use: opencode-runtime stop {existing.key}")
)
if existing is not None and manager.is_alive(key):
sys.exit(
_yellow(f"● Server already running id={existing.key} pid={existing.pid}\n")
+ _dim(f" use: opencode-runtime stop {existing.key}")
)

server_dir: Path | None = None
if runtime_dir is not None:
Expand Down Expand Up @@ -197,10 +182,10 @@ def cmd_ps(_args: argparse.Namespace) -> None:

for st in statuses:
e = st.entry
status_plain = f"{_STATUS_ICONS.get(st.display, '?')} {st.display.value}"
status_coloured = _status_display(st.display)
label = st.status.value if st.status is not None else "starting"
status_plain = f"{_STATUS_ICONS.get(st.status, '?')} {label}"
status_coloured = _status_display(st.status)

# Compute uptime
try:
started = datetime.fromisoformat(e.started_at)
uptime_secs = int((datetime.now(timezone.utc) - started).total_seconds())
Expand All @@ -210,7 +195,7 @@ def cmd_ps(_args: argparse.Namespace) -> None:
uptime_str = f"{uptime_secs // 60}m"
else:
uptime_str = f"{uptime_secs // 3600}h"
except Exception:
except Exception: # noqa: BLE001 — malformed timestamp, unknown format
uptime_str = "?"

vals = [e.key, str(e.pid), str(e.port), status_plain, uptime_str]
Expand Down Expand Up @@ -261,7 +246,7 @@ def cmd_stop_all(_args: argparse.Namespace) -> None:
asyncio.run(manager.stop_all())

print(f"{_green(f'✓ Stopped {len(entries)} server(s)')}\n")
for e, _ in entries:
for e in entries:
print(f" {_dim(e.key)} {_dim(f'pid {e.pid}')}")


Expand All @@ -277,7 +262,14 @@ def cmd_health(args: argparse.Namespace) -> None:
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
entry = st.entry

if st.display == DisplayStatus.RUNNING:
if st.status is None:
try:
claimed = datetime.fromisoformat(entry.started_at)
age_secs = int((datetime.now(timezone.utc) - claimed).total_seconds())
sys.exit(_yellow(f"◐ starting: claimed {age_secs}s ago, health check pending"))
except Exception: # noqa: BLE001 — malformed timestamp, unknown format
sys.exit(_yellow("◐ starting: awaiting health check"))
elif st.status == RuntimeStatus.RUNNING:
try:
result = asyncio.run(manager.health(args.key))
version = result.get("version")
Expand All @@ -286,27 +278,16 @@ def cmd_health(args: argparse.Namespace) -> None:
+ f" {_dim(f'version {version}')}"
+ f" {_dim(f'http://127.0.0.1:{entry.port}')}"
)
except Exception as exc:
except Exception as exc: # noqa: BLE001 — network error, timeout, etc.
sys.exit(_red(f"✗ unhealthy: /global/health failed: {exc}"))
elif st.display == DisplayStatus.STARTING:
try:
claimed = datetime.fromisoformat(entry.claimed_at)
age_secs = int((datetime.now(timezone.utc) - claimed).total_seconds())
sys.exit(_yellow(f"◐ starting: claimed {age_secs}s ago, health check pending"))
except Exception:
sys.exit(_yellow("◐ starting: awaiting health check"))
elif st.display == DisplayStatus.UNHEALTHY:
elif st.status == RuntimeStatus.UNHEALTHY:
sys.exit(
_red(
f"✗ unhealthy: process running (pid {entry.pid}) but /global/health endpoint failed"
)
)
elif st.display == DisplayStatus.STALE:
sys.exit(_red(f"✗ stale: registry entry exists but pid {entry.pid} is not running"))
elif st.display == DisplayStatus.FAILED:
sys.exit(_red("✗ failed: startup failed or lease expired"))
else:
sys.exit(_red(f"✗ unknown: {st.display}"))
sys.exit(_red(f"✗ stale: registry entry exists but pid {entry.pid} is not running"))


# ---------------------------------------------------------------------------
Expand All @@ -321,7 +302,6 @@ def cmd_inspect(args: argparse.Namespace) -> None:
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
entry = st.entry

# Compute uptime
try:
started = datetime.fromisoformat(entry.started_at)
uptime_secs = int((datetime.now(timezone.utc) - started).total_seconds())
Expand All @@ -331,28 +311,12 @@ def cmd_inspect(args: argparse.Namespace) -> None:
uptime = f"{uptime_secs // 60}m {uptime_secs % 60}s"
else:
uptime = f"{uptime_secs // 3600}h {(uptime_secs % 3600) // 60}m"
except Exception:
except Exception: # noqa: BLE001 — malformed timestamp, unknown format
uptime = "?"

# Compute idle time (time since last use)
if entry.last_used_at:
try:
last_used = datetime.fromisoformat(entry.last_used_at)
idle_secs = int((datetime.now(timezone.utc) - last_used).total_seconds())
if idle_secs < 60:
idle = f"{idle_secs}s ago"
elif idle_secs < 3600:
idle = f"{idle_secs // 60}m ago"
else:
idle = f"{idle_secs // 3600}h ago"
except Exception:
idle = "?"
else:
idle = "-"

print()
_row("ID", entry.key)
_row("Status", _status_display(st.display))
_row("Status", _status_display(st.status))
_row("Project", _home(entry.project_dir))
if entry.workspace:
_row("Workspace", entry.workspace)
Expand All @@ -361,7 +325,6 @@ def cmd_inspect(args: argparse.Namespace) -> None:
_row("PID", _dim(str(entry.pid)) if entry.pid else _dim("(none)"))
_row("Port", _dim(str(entry.port)))
_row("Uptime", uptime)
_row("Last used", idle)
if entry.runtime_version:
_row("Runtime", entry.runtime_version)
if entry.server_dir:
Expand Down
119 changes: 59 additions & 60 deletions src/opencode_runtime/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,64 +162,63 @@ async def events(self, session_id: str) -> t.AsyncIterator[OpenCodeEvent]:

Only ``sync`` bus-noise events are suppressed.
"""
async with self._http() as http:
async with http.stream("GET", "/global/event") as r:
async with self._http() as http, http.stream("GET", "/global/event") as r:
try:
r.raise_for_status()
except httpx.HTTPStatusError as exc:
raise OpenCodeServerError(
f"SSE /global/event returned {exc.response.status_code}"
) from exc

async for line in r.aiter_lines():
line = line.strip()

if not line.startswith("data:"):
continue

raw_data = line[len("data:") :].strip()
if not raw_data:
continue

try:
r.raise_for_status()
except httpx.HTTPStatusError as exc:
raise OpenCodeServerError(
f"SSE /global/event returned {exc.response.status_code}"
) from exc

async for line in r.aiter_lines():
line = line.strip()

if not line.startswith("data:"):
continue

raw_data = line[len("data:") :].strip()
if not raw_data:
continue

try:
envelope = json.loads(raw_data)
except json.JSONDecodeError:
continue

# /global/event wraps each event: {"payload": {"type": ..., "properties": ...}}
payload = envelope.get("payload", {})
if not isinstance(payload, dict):
continue

event_type = payload.get("type", "")

# Suppress internal bus noise — never useful to callers.
if event_type == "sync":
continue

props = payload.get("properties", {})
if not isinstance(props, dict):
props = {}

# Filter to this session (events without sessionID are global, pass through)
sid = props.get("sessionID")
if sid is not None and sid != session_id:
continue

# Derive a convenience text field where it naturally exists,
# so callers don't have to dig into raw for the common case.
text: str | None = None
if event_type == "message.part.delta" and props.get("field") == "text":
text = props.get("delta") or None
elif event_type == "message.part.updated":
part = props.get("part") or {}
if isinstance(part, dict) and part.get("type") == "text":
text = part.get("text") or None

yield OpenCodeEvent(type=event_type, text=text, raw=payload)

# Terminal events — stop iterating after yielding.
if event_type == "session.idle":
return
if event_type == "session.error":
return
envelope = json.loads(raw_data)
except json.JSONDecodeError:
continue

# /global/event wraps each event: {"payload": {"type": ..., "properties": ...}}
payload = envelope.get("payload", {})
if not isinstance(payload, dict):
continue

event_type = payload.get("type", "")

# Suppress internal bus noise — never useful to callers.
if event_type == "sync":
continue

props = payload.get("properties", {})
if not isinstance(props, dict):
props = {}

# Filter to this session (events without sessionID are global, pass through)
sid = props.get("sessionID")
if sid is not None and sid != session_id:
continue

# Derive a convenience text field where it naturally exists,
# so callers don't have to dig into raw for the common case.
text: str | None = None
if event_type == "message.part.delta" and props.get("field") == "text":
text = props.get("delta") or None
elif event_type == "message.part.updated":
part = props.get("part") or {}
if isinstance(part, dict) and part.get("type") == "text":
text = part.get("text") or None

yield OpenCodeEvent(type=event_type, text=text, raw=payload)

# Terminal events — stop iterating after yielding.
if event_type == "session.idle":
return
if event_type == "session.error":
return
8 changes: 2 additions & 6 deletions src/opencode_runtime/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,5 @@ class OpenCodeTimeoutError(OpenCodeRuntimeError):
"""Raised when a health check or request exceeds the allowed timeout."""


class RegistrySchemaError(OpenCodeRuntimeError):
"""Raised when the registry database's schema version can't be handled.

Either it's newer than this version of opencode-runtime understands, or
no migration is registered to bring it up to the current version.
"""
class RegistryBusyError(OpenCodeRuntimeError):
"""Raised when a registry entry's lock can't be acquired."""
Loading
Loading