opencode-runtime 0.4.1__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/.github/workflows/ci.yml +3 -1
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/CHANGELOG.md +17 -1
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/PKG-INFO +6 -5
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/README.md +5 -4
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/cli.md +26 -5
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/pyproject.toml +1 -1
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/__init__.py +2 -2
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/cli.py +151 -23
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/client.py +3 -2
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/event.py +0 -5
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/exceptions.py +8 -0
- opencode_runtime-0.5.0/src/opencode_runtime/registry.py +188 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/runtime.py +12 -8
- opencode_runtime-0.5.0/src/opencode_runtime/schema.py +79 -0
- opencode_runtime-0.5.0/src/opencode_runtime/server.py +549 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/session.py +2 -2
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_cli.py +19 -37
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_client.py +1 -2
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_multi_tenant.py +2 -19
- opencode_runtime-0.5.0/tests/test_registry.py +239 -0
- opencode_runtime-0.5.0/tests/test_runtime.py +114 -0
- opencode_runtime-0.5.0/tests/test_schema.py +106 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_server.py +134 -2
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_session.py +1 -2
- opencode_runtime-0.4.1/src/opencode_runtime/registry.py +0 -84
- opencode_runtime-0.4.1/src/opencode_runtime/server.py +0 -394
- opencode_runtime-0.4.1/tests/test_registry.py +0 -159
- opencode_runtime-0.4.1/tests/test_runtime.py +0 -152
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/.github/workflows/publish.yml +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/.gitignore +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/CONTRIBUTING.md +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/LICENSE +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/http-client.md +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/opencode-config.md +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/sessions.md +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/streaming.md +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/users-and-workspaces.md +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/py.typed +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/response.py +0 -0
- {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test.py +0 -0
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [0.5.0]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- SQLite-backed server registry at `~/.opencode-runtime/servers/registry.db` — persistent, atomic, supports concurrent claims
|
|
7
|
+
- `ServerState` enum (STARTING, RUNNING, STOPPING, FAILED) for type-safe state tracking
|
|
8
|
+
- Health probes: process liveness check + HTTP health endpoint verification; display status shows running/starting/unhealthy/stale/failed
|
|
9
|
+
- `inspect` command: detailed server info (PID, port, uptime, idle time, runtime version, health)
|
|
10
|
+
- Server metadata: track `runtime_version` and `last_used_at` timestamp
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Registry moved from PID files to SQLite — no API changes, same registry operations
|
|
14
|
+
- All state tracking uses `ServerState` enum instead of string literals (type safety)
|
|
15
|
+
- Server startup now lazy — contexts don't auto-create default server, start on first `session()` call
|
|
16
|
+
- `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)
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- one `OpenCodeRuntime` exiting would terminate servers created by other processes or CLI — now scoped to servers it actually started
|
|
4
20
|
|
|
5
21
|
## [0.4.1] - 2026-07-09
|
|
6
22
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-runtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Embed OpenCode in your Python application.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ashish16052/opencode-runtime
|
|
6
6
|
Project-URL: Repository, https://github.com/ashish16052/opencode-runtime
|
|
@@ -91,14 +91,15 @@ opencode-runtime ps
|
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
```
|
|
94
|
-
ID PID PORT STATUS
|
|
95
|
-
|
|
96
|
-
39dce5beb4debfaa 12051 58409 ●
|
|
97
|
-
81fa29acb3e9210f 12088 58411 ●
|
|
94
|
+
ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
|
|
95
|
+
───────────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
39dce5beb4debfaa 12051 58409 ● running 5m org_a u_1 ~/Developer/myproject
|
|
97
|
+
81fa29acb3e9210f 12088 58411 ● running 3m org_b u_2 ~/Developer/myproject
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
```sh
|
|
101
101
|
opencode-runtime health 39dce5beb4debfaa
|
|
102
|
+
opencode-runtime inspect 39dce5beb4debfaa
|
|
102
103
|
opencode-runtime stop-all
|
|
103
104
|
```
|
|
104
105
|
|
|
@@ -58,14 +58,15 @@ opencode-runtime ps
|
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
```
|
|
61
|
-
ID PID PORT STATUS
|
|
62
|
-
|
|
63
|
-
39dce5beb4debfaa 12051 58409 ●
|
|
64
|
-
81fa29acb3e9210f 12088 58411 ●
|
|
61
|
+
ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
|
|
62
|
+
───────────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
39dce5beb4debfaa 12051 58409 ● running 5m org_a u_1 ~/Developer/myproject
|
|
64
|
+
81fa29acb3e9210f 12088 58411 ● running 3m org_b u_2 ~/Developer/myproject
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
```sh
|
|
68
68
|
opencode-runtime health 39dce5beb4debfaa
|
|
69
|
+
opencode-runtime inspect 39dce5beb4debfaa
|
|
69
70
|
opencode-runtime stop-all
|
|
70
71
|
```
|
|
71
72
|
|
|
@@ -11,11 +11,11 @@ opencode-runtime ps
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
```
|
|
14
|
-
ID PID PORT STATUS
|
|
15
|
-
|
|
16
|
-
39dce5beb4debfaa 12051 58409 ●
|
|
17
|
-
81fa29acb3e9210f 12088 58411 ●
|
|
18
|
-
c3f2a1d9e8b74f05 13204 58413 ●
|
|
14
|
+
ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
|
|
15
|
+
───────────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
39dce5beb4debfaa 12051 58409 ● running 5m org_a u_1 ~/Developer/myproject
|
|
17
|
+
81fa29acb3e9210f 12088 58411 ● running 3m org_b u_2 ~/Developer/myproject
|
|
18
|
+
c3f2a1d9e8b74f05 13204 58413 ● running 1h org_c u_3 ~/Developer/myproject
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
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.
|
|
@@ -34,6 +34,26 @@ Pipe it into your alerting:
|
|
|
34
34
|
opencode-runtime health 39dce5beb4debfaa || pagerduty-alert "opencode server down"
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
## Inspect a server in detail
|
|
38
|
+
|
|
39
|
+
`health` tells you if a server is up; `inspect` tells you everything else — uptime, idle time, runtime version, log file location:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
opencode-runtime inspect 39dce5beb4debfaa
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
ID 39dce5beb4debfaa
|
|
47
|
+
Status ● running
|
|
48
|
+
Project ~/Developer/myproject
|
|
49
|
+
Workspace org_a
|
|
50
|
+
User u_1
|
|
51
|
+
PID 12051
|
|
52
|
+
Port 58409
|
|
53
|
+
Uptime 5m 12s
|
|
54
|
+
Last used 30s ago
|
|
55
|
+
```
|
|
56
|
+
|
|
37
57
|
## Start a server manually
|
|
38
58
|
|
|
39
59
|
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:
|
|
@@ -95,5 +115,6 @@ deploy.sh
|
|
|
95
115
|
| `opencode-runtime ps` | List all running servers with ID, PID, port, status, uptime, workspace, user, project |
|
|
96
116
|
| `opencode-runtime serve` | Start a background server. Accepts `--workspace`, `--user-id` |
|
|
97
117
|
| `opencode-runtime health <id>` | Health check a server by ID. Exits non-zero if unhealthy |
|
|
118
|
+
| `opencode-runtime inspect <id>` | Show detailed info for a server: uptime, idle time, runtime version, log file |
|
|
98
119
|
| `opencode-runtime stop <id>` | Stop a specific server by ID |
|
|
99
120
|
| `opencode-runtime stop-all` | Stop all running servers |
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
opencode-runtime: runtime infrastructure for multi-user OpenCode deployments.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
__version__ = "0.
|
|
5
|
+
__version__ = "0.5.0"
|
|
6
6
|
|
|
7
7
|
from .event import OpenCodeEvent
|
|
8
|
-
from .runtime import OpenCodeRuntime
|
|
9
8
|
from .response import OpenCodeResponse
|
|
9
|
+
from .runtime import OpenCodeRuntime
|
|
10
10
|
from .session import OpenCodeSession
|
|
11
11
|
|
|
12
12
|
__all__ = [
|
|
@@ -17,13 +17,14 @@ import sys
|
|
|
17
17
|
from datetime import datetime, timezone
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
from .server import ServerManager, _compute_runtime_key
|
|
20
|
+
from .server import DisplayStatus, ServerManager, _compute_runtime_key
|
|
21
21
|
|
|
22
22
|
# ---------------------------------------------------------------------------
|
|
23
23
|
# ANSI
|
|
24
24
|
# ---------------------------------------------------------------------------
|
|
25
25
|
|
|
26
26
|
_R = "\033[0m"
|
|
27
|
+
_DIM = "\033[2m"
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def _green(s: str) -> str:
|
|
@@ -43,7 +44,7 @@ def _cyan(s: str) -> str:
|
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def _dim(s: str) -> str:
|
|
46
|
-
return f"
|
|
47
|
+
return f"{_DIM}{s}{_R}"
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
# ---------------------------------------------------------------------------
|
|
@@ -76,6 +77,29 @@ def _row(label: str, value: str) -> None:
|
|
|
76
77
|
print(f" {_cyan(f'{label:<9}')} {value}")
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
_STATUS_ICONS: dict[DisplayStatus, str] = {
|
|
81
|
+
DisplayStatus.RUNNING: "●",
|
|
82
|
+
DisplayStatus.STARTING: "◐",
|
|
83
|
+
DisplayStatus.UNHEALTHY: "▲",
|
|
84
|
+
DisplayStatus.STALE: "○",
|
|
85
|
+
DisplayStatus.FAILED: "✗",
|
|
86
|
+
}
|
|
87
|
+
_STATUS_COLORS = {
|
|
88
|
+
DisplayStatus.RUNNING: _green,
|
|
89
|
+
DisplayStatus.STARTING: _yellow,
|
|
90
|
+
DisplayStatus.UNHEALTHY: _red,
|
|
91
|
+
DisplayStatus.STALE: _dim,
|
|
92
|
+
DisplayStatus.FAILED: _red,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _status_display(status: DisplayStatus) -> str:
|
|
97
|
+
"""Render a ServerStatus.display value as a coloured icon + label."""
|
|
98
|
+
icon = _STATUS_ICONS.get(status, "?")
|
|
99
|
+
color = _STATUS_COLORS.get(status, _dim)
|
|
100
|
+
return color(f"{icon} {status.value}")
|
|
101
|
+
|
|
102
|
+
|
|
79
103
|
# ---------------------------------------------------------------------------
|
|
80
104
|
# serve
|
|
81
105
|
# ---------------------------------------------------------------------------
|
|
@@ -152,11 +176,11 @@ def cmd_serve(args: argparse.Namespace) -> None:
|
|
|
152
176
|
|
|
153
177
|
|
|
154
178
|
def cmd_ps(_args: argparse.Namespace) -> None:
|
|
155
|
-
|
|
156
|
-
show_workspace = any(
|
|
157
|
-
show_user = any(
|
|
179
|
+
statuses = asyncio.run(ServerManager().list_statuses())
|
|
180
|
+
show_workspace = any(st.entry.workspace for st in statuses)
|
|
181
|
+
show_user = any(st.entry.user_id for st in statuses)
|
|
158
182
|
|
|
159
|
-
cols = [" {:<18}", "{:>6}", "{:>6}", "{:<
|
|
183
|
+
cols = [" {:<18}", "{:>6}", "{:>6}", "{:<11}", "{:>8}"]
|
|
160
184
|
headers = ["ID", "PID", "PORT", "STATUS", "UPTIME"]
|
|
161
185
|
if show_workspace:
|
|
162
186
|
cols.append("{:<12}")
|
|
@@ -171,18 +195,33 @@ def cmd_ps(_args: argparse.Namespace) -> None:
|
|
|
171
195
|
print(_cyan(fmt.format(*headers)))
|
|
172
196
|
print(_dim(" " + "─" * (70 + 14 * show_workspace + 14 * show_user)))
|
|
173
197
|
|
|
174
|
-
for
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
198
|
+
for st in statuses:
|
|
199
|
+
e = st.entry
|
|
200
|
+
status_plain = f"{_STATUS_ICONS.get(st.display, '?')} {st.display.value}"
|
|
201
|
+
status_coloured = _status_display(st.display)
|
|
202
|
+
|
|
203
|
+
# Compute uptime
|
|
204
|
+
try:
|
|
205
|
+
started = datetime.fromisoformat(e.started_at)
|
|
206
|
+
uptime_secs = int((datetime.now(timezone.utc) - started).total_seconds())
|
|
207
|
+
if uptime_secs < 60:
|
|
208
|
+
uptime_str = f"{uptime_secs}s"
|
|
209
|
+
elif uptime_secs < 3600:
|
|
210
|
+
uptime_str = f"{uptime_secs // 60}m"
|
|
211
|
+
else:
|
|
212
|
+
uptime_str = f"{uptime_secs // 3600}h"
|
|
213
|
+
except Exception:
|
|
214
|
+
uptime_str = "?"
|
|
215
|
+
|
|
216
|
+
vals = [e.key, str(e.pid), str(e.port), status_plain, uptime_str]
|
|
178
217
|
if show_workspace:
|
|
179
218
|
vals.append(e.workspace or "-")
|
|
180
219
|
if show_user:
|
|
181
220
|
vals.append(e.user_id or "-")
|
|
182
221
|
vals.append(_home(e.project_dir))
|
|
183
222
|
row = fmt.format(*vals)
|
|
184
|
-
|
|
185
|
-
print(
|
|
223
|
+
dimmed_row = _dim(row).replace(status_plain, f"{status_coloured}{_DIM}", 1)
|
|
224
|
+
print(dimmed_row)
|
|
186
225
|
|
|
187
226
|
|
|
188
227
|
# ---------------------------------------------------------------------------
|
|
@@ -191,8 +230,10 @@ def cmd_ps(_args: argparse.Namespace) -> None:
|
|
|
191
230
|
|
|
192
231
|
|
|
193
232
|
def cmd_stop(args: argparse.Namespace) -> None:
|
|
233
|
+
from . import registry
|
|
234
|
+
|
|
194
235
|
manager = ServerManager()
|
|
195
|
-
entry =
|
|
236
|
+
entry = registry.read(args.key)
|
|
196
237
|
if entry is None:
|
|
197
238
|
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
|
|
198
239
|
|
|
@@ -202,7 +243,7 @@ def cmd_stop(args: argparse.Namespace) -> None:
|
|
|
202
243
|
|
|
203
244
|
print(f"{_green('✓ Server stopped')}\n")
|
|
204
245
|
_row("ID", entry.key)
|
|
205
|
-
_row("PID", _dim(str(entry.pid)))
|
|
246
|
+
_row("PID", _dim(str(entry.pid)) if entry.pid else _dim("(none)"))
|
|
206
247
|
|
|
207
248
|
|
|
208
249
|
# ---------------------------------------------------------------------------
|
|
@@ -230,20 +271,103 @@ def cmd_stop_all(_args: argparse.Namespace) -> None:
|
|
|
230
271
|
|
|
231
272
|
|
|
232
273
|
def cmd_health(args: argparse.Namespace) -> None:
|
|
233
|
-
|
|
274
|
+
manager = ServerManager()
|
|
275
|
+
st = asyncio.run(manager.status(args.key))
|
|
276
|
+
if st is None:
|
|
277
|
+
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
|
|
278
|
+
entry = st.entry
|
|
279
|
+
|
|
280
|
+
if st.display == DisplayStatus.RUNNING:
|
|
281
|
+
try:
|
|
282
|
+
result = asyncio.run(manager.health(args.key))
|
|
283
|
+
version = result.get("version")
|
|
284
|
+
print(
|
|
285
|
+
_green("✓ healthy")
|
|
286
|
+
+ f" {_dim(f'version {version}')}"
|
|
287
|
+
+ f" {_dim(f'http://127.0.0.1:{entry.port}')}"
|
|
288
|
+
)
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
sys.exit(_red(f"✗ unhealthy: /global/health failed: {exc}"))
|
|
291
|
+
elif st.display == DisplayStatus.STARTING:
|
|
292
|
+
try:
|
|
293
|
+
claimed = datetime.fromisoformat(entry.claimed_at)
|
|
294
|
+
age_secs = int((datetime.now(timezone.utc) - claimed).total_seconds())
|
|
295
|
+
sys.exit(_yellow(f"◐ starting: claimed {age_secs}s ago, health check pending"))
|
|
296
|
+
except Exception:
|
|
297
|
+
sys.exit(_yellow("◐ starting: awaiting health check"))
|
|
298
|
+
elif st.display == DisplayStatus.UNHEALTHY:
|
|
299
|
+
sys.exit(
|
|
300
|
+
_red(
|
|
301
|
+
f"✗ unhealthy: process running (pid {entry.pid}) but /global/health endpoint failed"
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
elif st.display == DisplayStatus.STALE:
|
|
305
|
+
sys.exit(_red(f"✗ stale: registry entry exists but pid {entry.pid} is not running"))
|
|
306
|
+
elif st.display == DisplayStatus.FAILED:
|
|
307
|
+
sys.exit(_red("✗ failed: startup failed or lease expired"))
|
|
308
|
+
else:
|
|
309
|
+
sys.exit(_red(f"✗ unknown: {st.display}"))
|
|
310
|
+
|
|
234
311
|
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# inspect
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cmd_inspect(args: argparse.Namespace) -> None:
|
|
235
318
|
manager = ServerManager()
|
|
236
|
-
|
|
237
|
-
if
|
|
319
|
+
st = asyncio.run(manager.status(args.key))
|
|
320
|
+
if st is None:
|
|
238
321
|
sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
|
|
322
|
+
entry = st.entry
|
|
239
323
|
|
|
240
|
-
|
|
324
|
+
# Compute uptime
|
|
241
325
|
try:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
326
|
+
started = datetime.fromisoformat(entry.started_at)
|
|
327
|
+
uptime_secs = int((datetime.now(timezone.utc) - started).total_seconds())
|
|
328
|
+
if uptime_secs < 60:
|
|
329
|
+
uptime = f"{uptime_secs}s"
|
|
330
|
+
elif uptime_secs < 3600:
|
|
331
|
+
uptime = f"{uptime_secs // 60}m {uptime_secs % 60}s"
|
|
332
|
+
else:
|
|
333
|
+
uptime = f"{uptime_secs // 3600}h {(uptime_secs % 3600) // 60}m"
|
|
334
|
+
except Exception:
|
|
335
|
+
uptime = "?"
|
|
336
|
+
|
|
337
|
+
# Compute idle time (time since last use)
|
|
338
|
+
if entry.last_used_at:
|
|
339
|
+
try:
|
|
340
|
+
last_used = datetime.fromisoformat(entry.last_used_at)
|
|
341
|
+
idle_secs = int((datetime.now(timezone.utc) - last_used).total_seconds())
|
|
342
|
+
if idle_secs < 60:
|
|
343
|
+
idle = f"{idle_secs}s ago"
|
|
344
|
+
elif idle_secs < 3600:
|
|
345
|
+
idle = f"{idle_secs // 60}m ago"
|
|
346
|
+
else:
|
|
347
|
+
idle = f"{idle_secs // 3600}h ago"
|
|
348
|
+
except Exception:
|
|
349
|
+
idle = "?"
|
|
350
|
+
else:
|
|
351
|
+
idle = "-"
|
|
352
|
+
|
|
353
|
+
print()
|
|
354
|
+
_row("ID", entry.key)
|
|
355
|
+
_row("Status", _status_display(st.display))
|
|
356
|
+
_row("Project", _home(entry.project_dir))
|
|
357
|
+
if entry.workspace:
|
|
358
|
+
_row("Workspace", entry.workspace)
|
|
359
|
+
if entry.user_id:
|
|
360
|
+
_row("User", entry.user_id)
|
|
361
|
+
_row("PID", _dim(str(entry.pid)) if entry.pid else _dim("(none)"))
|
|
362
|
+
_row("Port", _dim(str(entry.port)))
|
|
363
|
+
_row("Uptime", uptime)
|
|
364
|
+
_row("Last used", idle)
|
|
365
|
+
if entry.runtime_version:
|
|
366
|
+
_row("Runtime", entry.runtime_version)
|
|
367
|
+
if entry.server_dir:
|
|
368
|
+
log_file = _home(str(Path(entry.server_dir) / "opencode.log"))
|
|
369
|
+
_row("Log file", _dim(log_file))
|
|
370
|
+
print()
|
|
247
371
|
|
|
248
372
|
|
|
249
373
|
# ---------------------------------------------------------------------------
|
|
@@ -291,6 +415,10 @@ def main() -> None:
|
|
|
291
415
|
p.add_argument("key", help="server id (from ps)")
|
|
292
416
|
p.set_defaults(func=cmd_health)
|
|
293
417
|
|
|
418
|
+
p = sub.add_parser("inspect", help="show detailed server information")
|
|
419
|
+
p.add_argument("key", help="server id (from ps)")
|
|
420
|
+
p.set_defaults(func=cmd_inspect)
|
|
421
|
+
|
|
294
422
|
if len(sys.argv) == 1:
|
|
295
423
|
parser.print_help()
|
|
296
424
|
sys.exit(0)
|
|
@@ -24,8 +24,9 @@ class OpenCodeClient:
|
|
|
24
24
|
Args:
|
|
25
25
|
base_url: Base URL of the running opencode server,
|
|
26
26
|
e.g. ``"http://127.0.0.1:4096"``.
|
|
27
|
-
password: Value of ``OPENCODE_SERVER_PASSWORD``. Sent as
|
|
28
|
-
``Authorization:
|
|
27
|
+
password: Value of ``OPENCODE_SERVER_PASSWORD``. Sent as HTTP Basic
|
|
28
|
+
auth (``Authorization: Basic base64("opencode:<password>")``)
|
|
29
|
+
when set.
|
|
29
30
|
timeout: Default request timeout in seconds.
|
|
30
31
|
"""
|
|
31
32
|
|
|
@@ -12,3 +12,11 @@ class OpenCodeServerError(OpenCodeRuntimeError):
|
|
|
12
12
|
|
|
13
13
|
class OpenCodeTimeoutError(OpenCodeRuntimeError):
|
|
14
14
|
"""Raised when a health check or request exceeds the allowed timeout."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RegistrySchemaError(OpenCodeRuntimeError):
|
|
18
|
+
"""Raised when the registry database's schema version can't be handled.
|
|
19
|
+
|
|
20
|
+
Either it's newer than this version of opencode-runtime understands, or
|
|
21
|
+
no migration is registered to bring it up to the current version.
|
|
22
|
+
"""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry for tracking OpenCode instance processes.
|
|
3
|
+
|
|
4
|
+
Every running instance is a row in a SQLite database at:
|
|
5
|
+
~/.opencode-runtime/servers/registry.db
|
|
6
|
+
|
|
7
|
+
Used by both the CLI (opencode-runtime serve/ps/stop) and the library
|
|
8
|
+
(OpenCodeRuntime) — the registry is the shared source of truth for all
|
|
9
|
+
running instances regardless of how they were started.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sqlite3
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Generator
|
|
22
|
+
|
|
23
|
+
from .schema import SCHEMA, SCHEMA_VERSION, migrate
|
|
24
|
+
|
|
25
|
+
REGISTRY_DIR = Path(
|
|
26
|
+
os.environ.get("OPENCODE_RUNTIME_REGISTRY_DIR")
|
|
27
|
+
or (Path.home() / ".opencode-runtime" / "servers")
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# A 'starting' row older than this is treated as abandoned (its starter
|
|
31
|
+
# crashed — SIGKILL, host reboot — before reaching write()/delete()) and is
|
|
32
|
+
# reclaimed by the next claim_starting() call. Comfortably above
|
|
33
|
+
# _wait_healthy's default 60s startup timeout, so a claim only expires once
|
|
34
|
+
# a start attempt has definitely either finished or died without cleaning
|
|
35
|
+
# up after itself.
|
|
36
|
+
_START_LEASE_SECONDS = 90
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ServerState(str, Enum):
|
|
40
|
+
"""Server lifecycle state.
|
|
41
|
+
|
|
42
|
+
STARTING: claimed startup slot, awaiting health check.
|
|
43
|
+
RUNNING: process alive, health check passing.
|
|
44
|
+
STOPPING: shutdown initiated (reserved for future use).
|
|
45
|
+
FAILED: startup failed or lease expired (reserved for future use).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
STARTING = "starting"
|
|
49
|
+
RUNNING = "running"
|
|
50
|
+
STOPPING = "stopping"
|
|
51
|
+
FAILED = "failed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RegistryEntry:
|
|
56
|
+
"""A server entry in the registry.
|
|
57
|
+
|
|
58
|
+
state: ServerState enum value. Display status is derived from state + observed health.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
key: str
|
|
62
|
+
state: ServerState
|
|
63
|
+
pid: int | None
|
|
64
|
+
port: int
|
|
65
|
+
password: str
|
|
66
|
+
project_dir: str
|
|
67
|
+
server_dir: str | None
|
|
68
|
+
started_at: str # ISO-8601
|
|
69
|
+
claimed_at: str # ISO-8601; only meaningful while state == "starting"
|
|
70
|
+
workspace: str | None = None
|
|
71
|
+
user_id: str | None = None
|
|
72
|
+
last_used_at: str | None = None
|
|
73
|
+
runtime_version: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_INSERT = """
|
|
77
|
+
INSERT INTO servers (key, state, pid, port, password, project_dir, server_dir,
|
|
78
|
+
started_at, claimed_at, workspace, user_id, last_used_at,
|
|
79
|
+
runtime_version)
|
|
80
|
+
VALUES (:key, :state, :pid, :port, :password, :project_dir, :server_dir,
|
|
81
|
+
:started_at, :claimed_at, :workspace, :user_id, :last_used_at,
|
|
82
|
+
:runtime_version)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
_UPSERT = _INSERT.replace("INSERT", "INSERT OR REPLACE", 1)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@contextmanager
|
|
89
|
+
def _connect() -> Generator[sqlite3.Connection, None, None]:
|
|
90
|
+
"""Open the registry database, creating it on first use.
|
|
91
|
+
|
|
92
|
+
Commits on clean exit, rolls back if the body raises. The 5s timeout is
|
|
93
|
+
SQLite's busy timeout — concurrent writers wait for each other instead
|
|
94
|
+
of failing immediately.
|
|
95
|
+
"""
|
|
96
|
+
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
db_path = REGISTRY_DIR / "registry.db"
|
|
98
|
+
is_new = not db_path.exists()
|
|
99
|
+
conn = sqlite3.connect(db_path, timeout=5.0)
|
|
100
|
+
try:
|
|
101
|
+
conn.row_factory = sqlite3.Row
|
|
102
|
+
conn.execute(SCHEMA)
|
|
103
|
+
if is_new:
|
|
104
|
+
db_path.chmod(0o600) # entries hold server passwords
|
|
105
|
+
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
106
|
+
else:
|
|
107
|
+
current_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
108
|
+
if current_version != SCHEMA_VERSION:
|
|
109
|
+
migrate(conn, current_version)
|
|
110
|
+
yield conn
|
|
111
|
+
conn.commit()
|
|
112
|
+
finally:
|
|
113
|
+
conn.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _entry(row: sqlite3.Row) -> RegistryEntry:
|
|
117
|
+
# Column names match RegistryEntry field names one-to-one.
|
|
118
|
+
return RegistryEntry(**{name: row[name] for name in row.keys()})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def write(entry: RegistryEntry) -> None:
|
|
122
|
+
"""Write (insert or replace) a registry entry."""
|
|
123
|
+
with _connect() as conn:
|
|
124
|
+
conn.execute(_UPSERT, asdict(entry))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read(key: str) -> RegistryEntry | None:
|
|
128
|
+
"""Read a registry entry by key. Returns None if not found."""
|
|
129
|
+
with _connect() as conn:
|
|
130
|
+
row = conn.execute("SELECT * FROM servers WHERE key = ?", (key,)).fetchone()
|
|
131
|
+
return _entry(row) if row is not None else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def delete(key: str) -> None:
|
|
135
|
+
"""Remove a registry entry. No-op if not found."""
|
|
136
|
+
with _connect() as conn:
|
|
137
|
+
conn.execute("DELETE FROM servers WHERE key = ?", (key,))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def list_all() -> list[RegistryEntry]:
|
|
141
|
+
"""Return all registry entries."""
|
|
142
|
+
with _connect() as conn:
|
|
143
|
+
rows = conn.execute("SELECT * FROM servers").fetchall()
|
|
144
|
+
return [_entry(row) for row in rows]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def claim_starting(entry: RegistryEntry) -> bool:
|
|
148
|
+
"""Atomically insert a 'starting' row for entry.key.
|
|
149
|
+
|
|
150
|
+
Returns True if this call claimed the key, False if a row for the key
|
|
151
|
+
already exists (a live 'starting' claim or a 'ready' server). A
|
|
152
|
+
'starting' row older than _START_LEASE_SECONDS is treated as abandoned
|
|
153
|
+
and reclaimed within the same transaction.
|
|
154
|
+
"""
|
|
155
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_START_LEASE_SECONDS)).isoformat(
|
|
156
|
+
timespec="microseconds"
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
with _connect() as conn:
|
|
160
|
+
conn.execute(
|
|
161
|
+
"DELETE FROM servers WHERE key = ? AND state = ? AND claimed_at < ?",
|
|
162
|
+
(entry.key, ServerState.STARTING.value, cutoff),
|
|
163
|
+
)
|
|
164
|
+
conn.execute(_INSERT, asdict(entry))
|
|
165
|
+
return True
|
|
166
|
+
except sqlite3.IntegrityError: # PRIMARY KEY conflict — someone else holds the key
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def is_alive(pid: int | None) -> bool:
|
|
171
|
+
"""Return True if pid is set and a process with it is running."""
|
|
172
|
+
if pid is None:
|
|
173
|
+
return False
|
|
174
|
+
try:
|
|
175
|
+
os.kill(pid, 0)
|
|
176
|
+
return True
|
|
177
|
+
except (ProcessLookupError, PermissionError):
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def now_iso() -> str:
|
|
182
|
+
"""Return current UTC time as ISO-8601 string with fixed microsecond precision.
|
|
183
|
+
|
|
184
|
+
Fixed precision (rather than omitting the fraction when it's exactly
|
|
185
|
+
zero, as isoformat() does by default) keeps these strings correctly
|
|
186
|
+
orderable by plain string comparison, e.g. in claim_starting()'s lease check.
|
|
187
|
+
"""
|
|
188
|
+
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
|