Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7fce8c2
fix: remove OpenCodeEvent.to_sse()
ashish16052 Jul 9, 2026
37b8612
fix: rename OpenCodeSession.close() to reset()
ashish16052 Jul 9, 2026
6a9538d
feat: add SQLite-backed server tracking with atomic claims
ashish16052 Jul 10, 2026
8e2dd27
refactor: add ServerState enum
ashish16052 Jul 10, 2026
6992661
feat: add health probe and status computation
ashish16052 Jul 10, 2026
18035cc
feat: track server version and last_used_at
ashish16052 Jul 10, 2026
5384894
feat: add inspect command for detailed server info
ashish16052 Jul 10, 2026
83054de
refactor: replace all string state literals with ServerState enum
ashish16052 Jul 10, 2026
83ed42f
fix: make server startup lazy
ashish16052 Jul 10, 2026
9097de9
fix: scope runtime cleanup to servers it started
ashish16052 Jul 10, 2026
464b640
test: update tests for lazy startup and ownership-scoped close
ashish16052 Jul 10, 2026
7cb9220
refactor: move health/status derivation into ServerManager
ashish16052 Jul 10, 2026
5c8331d
feat: add registry schema versioning and migration mechanism
ashish16052 Jul 10, 2026
b5292e3
fix: stop ps status color from breaking row dim styling
ashish16052 Jul 10, 2026
f5cbe71
fix: let cmd_stop stop non-RUNNING registry entries
ashish16052 Jul 10, 2026
daca658
docs: correct OpenCodeClient auth docstring
ashish16052 Jul 10, 2026
88e69e0
docs: update readme and examples
ashish16052 Jul 10, 2026
59b364d
test: remove flaky ci test
ashish16052 Jul 10, 2026
8757e76
docs: add CHANGELOG for v0.5.0
ashish16052 Jul 10, 2026
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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
# Changelog

## [Unreleased]
## [0.5.0]

### Added
- SQLite-backed server registry at `~/.opencode-runtime/servers/registry.db` — persistent, atomic, supports concurrent claims
- `ServerState` enum (STARTING, RUNNING, STOPPING, FAILED) for type-safe state tracking
- Health probes: process liveness check + HTTP health endpoint verification; display status shows running/starting/unhealthy/stale/failed
- `inspect` command: detailed server info (PID, port, uptime, idle time, runtime version, health)
- Server metadata: track `runtime_version` and `last_used_at` timestamp

### Changed
- Registry moved from PID files to SQLite — no API changes, same registry operations
- All state tracking uses `ServerState` enum instead of string literals (type safety)
- Server startup now lazy — contexts don't auto-create default server, start on first `session()` call
- `runtime.close()` now stops only servers that `OpenCodeRuntime` instance itself started; servers it merely attached to (already running, started by another process or the CLI) are left alone (safe for multi-process deployments)

### Fixed
- one `OpenCodeRuntime` exiting would terminate servers created by other processes or CLI — now scoped to servers it actually started

## [0.4.1] - 2026-07-09

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,15 @@ opencode-runtime ps
```

```
ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
──────────────────────────────────────────────────────────────────────────────────
39dce5beb4debfaa 12051 58409 ● alive Up 5m org_a u_1 ~/Developer/myproject
81fa29acb3e9210f 12088 58411 ● alive Up 3m org_b u_2 ~/Developer/myproject
ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
──────────────────────────────────────────────────────────────────────────────────
39dce5beb4debfaa 12051 58409 ● running 5m org_a u_1 ~/Developer/myproject
81fa29acb3e9210f 12088 58411 ● running 3m org_b u_2 ~/Developer/myproject
```

```sh
opencode-runtime health 39dce5beb4debfaa
opencode-runtime inspect 39dce5beb4debfaa
opencode-runtime stop-all
```

Expand Down
31 changes: 26 additions & 5 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ opencode-runtime ps
```

```
ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
──────────────────────────────────────────────────────────────────────────────────
39dce5beb4debfaa 12051 58409 ● alive Up 5m org_a u_1 ~/Developer/myproject
81fa29acb3e9210f 12088 58411 ● alive Up 3m org_b u_2 ~/Developer/myproject
c3f2a1d9e8b74f05 13204 58413 ● alive Up 1h org_c u_3 ~/Developer/myproject
ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
──────────────────────────────────────────────────────────────────────────────────
39dce5beb4debfaa 12051 58409 ● running 5m org_a u_1 ~/Developer/myproject
81fa29acb3e9210f 12088 58411 ● running 3m org_b u_2 ~/Developer/myproject
c3f2a1d9e8b74f05 13204 58413 ● running 1h org_c u_3 ~/Developer/myproject
```

Every server your Python app has started — across all users and workspaces — is visible here. PID, port, uptime, which tenant, which user, which project. No guessing, no digging through logs.
Expand All @@ -34,6 +34,26 @@ Pipe it into your alerting:
opencode-runtime health 39dce5beb4debfaa || pagerduty-alert "opencode server down"
```

## Inspect a server in detail

`health` tells you if a server is up; `inspect` tells you everything else — uptime, idle time, runtime version, log file location:

```sh
opencode-runtime inspect 39dce5beb4debfaa
```

```
ID 39dce5beb4debfaa
Status ● running
Project ~/Developer/myproject
Workspace org_a
User u_1
PID 12051
Port 58409
Uptime 5m 12s
Last used 30s ago
```

## Start a server manually

Spin up a server outside of Python — useful for pre-warming tenants before they hit your API, or for running one-off tasks from the terminal:
Expand Down Expand Up @@ -95,5 +115,6 @@ deploy.sh
| `opencode-runtime ps` | List all running servers with ID, PID, port, status, uptime, workspace, user, project |
| `opencode-runtime serve` | Start a background server. Accepts `--workspace`, `--user-id` |
| `opencode-runtime health <id>` | Health check a server by ID. Exits non-zero if unhealthy |
| `opencode-runtime inspect <id>` | Show detailed info for a server: uptime, idle time, runtime version, log file |
| `opencode-runtime stop <id>` | Stop a specific server by ID |
| `opencode-runtime stop-all` | Stop all running servers |
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "opencode-runtime"
version = "0.4.1"
version = "0.5.0"
description = "Embed OpenCode in your Python application."
readme = "README.md"
requires-python = ">=3.10"
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 @@ -2,11 +2,11 @@
opencode-runtime: runtime infrastructure for multi-user OpenCode deployments.
"""

__version__ = "0.4.1"
__version__ = "0.5.0"

from .event import OpenCodeEvent
from .runtime import OpenCodeRuntime
from .response import OpenCodeResponse
from .runtime import OpenCodeRuntime
from .session import OpenCodeSession

__all__ = [
Expand Down
174 changes: 151 additions & 23 deletions src/opencode_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@
from datetime import datetime, timezone
from pathlib import Path

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

# ---------------------------------------------------------------------------
# ANSI
# ---------------------------------------------------------------------------

_R = "\033[0m"
_DIM = "\033[2m"


def _green(s: str) -> str:
Expand All @@ -43,7 +44,7 @@ def _cyan(s: str) -> str:


def _dim(s: str) -> str:
return f"\033[2m{s}{_R}"
return f"{_DIM}{s}{_R}"


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -76,6 +77,29 @@ 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: "✗",
}
_STATUS_COLORS = {
DisplayStatus.RUNNING: _green,
DisplayStatus.STARTING: _yellow,
DisplayStatus.UNHEALTHY: _red,
DisplayStatus.STALE: _dim,
DisplayStatus.FAILED: _red,
}


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


# ---------------------------------------------------------------------------
# serve
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -152,11 +176,11 @@ def cmd_serve(args: argparse.Namespace) -> None:


def cmd_ps(_args: argparse.Namespace) -> None:
entries = ServerManager().list()
show_workspace = any(e.workspace for e, _ in entries)
show_user = any(e.user_id for e, _ in entries)
statuses = asyncio.run(ServerManager().list_statuses())
show_workspace = any(st.entry.workspace for st in statuses)
show_user = any(st.entry.user_id for st in statuses)

cols = [" {:<18}", "{:>6}", "{:>6}", "{:<7}", "{:>8}"]
cols = [" {:<18}", "{:>6}", "{:>6}", "{:<11}", "{:>8}"]
headers = ["ID", "PID", "PORT", "STATUS", "UPTIME"]
if show_workspace:
cols.append("{:<12}")
Expand All @@ -171,18 +195,33 @@ def cmd_ps(_args: argparse.Namespace) -> None:
print(_cyan(fmt.format(*headers)))
print(_dim(" " + "─" * (70 + 14 * show_workspace + 14 * show_user)))

for e, alive in entries:
status_plain = "● alive" if alive else "● dead"
status_coloured = _green(status_plain) if alive else _red(status_plain)
vals = [e.key, str(e.pid), str(e.port), status_plain, _uptime(e.started_at, alive)]
for st in statuses:
e = st.entry
status_plain = f"{_STATUS_ICONS.get(st.display, '?')} {st.display.value}"
status_coloured = _status_display(st.display)

# Compute uptime
try:
started = datetime.fromisoformat(e.started_at)
uptime_secs = int((datetime.now(timezone.utc) - started).total_seconds())
if uptime_secs < 60:
uptime_str = f"{uptime_secs}s"
elif uptime_secs < 3600:
uptime_str = f"{uptime_secs // 60}m"
else:
uptime_str = f"{uptime_secs // 3600}h"
except Exception:
uptime_str = "?"

vals = [e.key, str(e.pid), str(e.port), status_plain, uptime_str]
if show_workspace:
vals.append(e.workspace or "-")
if show_user:
vals.append(e.user_id or "-")
vals.append(_home(e.project_dir))
row = fmt.format(*vals)
row = row.replace(status_plain, status_coloured, 1)
print(_dim(row).replace(_dim(status_coloured), status_coloured, 1))
dimmed_row = _dim(row).replace(status_plain, f"{status_coloured}{_DIM}", 1)
print(dimmed_row)


# ---------------------------------------------------------------------------
Expand All @@ -191,8 +230,10 @@ def cmd_ps(_args: argparse.Namespace) -> None:


def cmd_stop(args: argparse.Namespace) -> None:
from . import registry

manager = ServerManager()
entry = manager.find(args.key)
entry = registry.read(args.key)
if entry is None:
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))

Expand All @@ -202,7 +243,7 @@ def cmd_stop(args: argparse.Namespace) -> None:

print(f"{_green('✓ Server stopped')}\n")
_row("ID", entry.key)
_row("PID", _dim(str(entry.pid)))
_row("PID", _dim(str(entry.pid)) if entry.pid else _dim("(none)"))


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -230,20 +271,103 @@ def cmd_stop_all(_args: argparse.Namespace) -> None:


def cmd_health(args: argparse.Namespace) -> None:
from .exceptions import OpenCodeServerError
manager = ServerManager()
st = asyncio.run(manager.status(args.key))
if st is None:
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
entry = st.entry

if st.display == DisplayStatus.RUNNING:
try:
result = asyncio.run(manager.health(args.key))
version = result.get("version")
print(
_green("✓ healthy")
+ f" {_dim(f'version {version}')}"
+ f" {_dim(f'http://127.0.0.1:{entry.port}')}"
)
except Exception as exc:
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:
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}"))


# ---------------------------------------------------------------------------
# inspect
# ---------------------------------------------------------------------------


def cmd_inspect(args: argparse.Namespace) -> None:
manager = ServerManager()
entry = manager.find(args.key)
if entry is None:
st = asyncio.run(manager.status(args.key))
if st is None:
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
entry = st.entry

url = f"http://127.0.0.1:{entry.port}"
# Compute uptime
try:
result = asyncio.run(manager.health(args.key))
version = result.get("version")
print(_green("✓ healthy") + f" {_dim(f'version {version}')}" + f" {_dim(url)}")
except OpenCodeServerError as exc:
sys.exit(_red(f"✗ unreachable {url}\n {exc}"))
started = datetime.fromisoformat(entry.started_at)
uptime_secs = int((datetime.now(timezone.utc) - started).total_seconds())
if uptime_secs < 60:
uptime = f"{uptime_secs}s"
elif uptime_secs < 3600:
uptime = f"{uptime_secs // 60}m {uptime_secs % 60}s"
else:
uptime = f"{uptime_secs // 3600}h {(uptime_secs % 3600) // 60}m"
except Exception:
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("Project", _home(entry.project_dir))
if entry.workspace:
_row("Workspace", entry.workspace)
if entry.user_id:
_row("User", entry.user_id)
_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:
log_file = _home(str(Path(entry.server_dir) / "opencode.log"))
_row("Log file", _dim(log_file))
print()


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -291,6 +415,10 @@ def main() -> None:
p.add_argument("key", help="server id (from ps)")
p.set_defaults(func=cmd_health)

p = sub.add_parser("inspect", help="show detailed server information")
p.add_argument("key", help="server id (from ps)")
p.set_defaults(func=cmd_inspect)

if len(sys.argv) == 1:
parser.print_help()
sys.exit(0)
Expand Down
5 changes: 3 additions & 2 deletions src/opencode_runtime/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ class OpenCodeClient:
Args:
base_url: Base URL of the running opencode server,
e.g. ``"http://127.0.0.1:4096"``.
password: Value of ``OPENCODE_SERVER_PASSWORD``. Sent as
``Authorization: Bearer <password>`` when set.
password: Value of ``OPENCODE_SERVER_PASSWORD``. Sent as HTTP Basic
auth (``Authorization: Basic base64("opencode:<password>")``)
when set.
timeout: Default request timeout in seconds.
"""

Expand Down
5 changes: 0 additions & 5 deletions src/opencode_runtime/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,3 @@ class OpenCodeEvent:
type: str
text: str | None = None
raw: Any = None

def to_sse(self) -> str:
"""Format as a Server-Sent Events string suitable for HTTP streaming."""
data = self.text or ""
return f"event: {self.type}\ndata: {data}\n\n"
Loading
Loading