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.
Files changed (40) hide show
  1. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/.github/workflows/ci.yml +3 -1
  2. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/CHANGELOG.md +17 -1
  3. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/PKG-INFO +6 -5
  4. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/README.md +5 -4
  5. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/cli.md +26 -5
  6. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/pyproject.toml +1 -1
  7. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/__init__.py +2 -2
  8. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/cli.py +151 -23
  9. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/client.py +3 -2
  10. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/event.py +0 -5
  11. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/exceptions.py +8 -0
  12. opencode_runtime-0.5.0/src/opencode_runtime/registry.py +188 -0
  13. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/runtime.py +12 -8
  14. opencode_runtime-0.5.0/src/opencode_runtime/schema.py +79 -0
  15. opencode_runtime-0.5.0/src/opencode_runtime/server.py +549 -0
  16. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/session.py +2 -2
  17. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_cli.py +19 -37
  18. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_client.py +1 -2
  19. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_multi_tenant.py +2 -19
  20. opencode_runtime-0.5.0/tests/test_registry.py +239 -0
  21. opencode_runtime-0.5.0/tests/test_runtime.py +114 -0
  22. opencode_runtime-0.5.0/tests/test_schema.py +106 -0
  23. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_server.py +134 -2
  24. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test_session.py +1 -2
  25. opencode_runtime-0.4.1/src/opencode_runtime/registry.py +0 -84
  26. opencode_runtime-0.4.1/src/opencode_runtime/server.py +0 -394
  27. opencode_runtime-0.4.1/tests/test_registry.py +0 -159
  28. opencode_runtime-0.4.1/tests/test_runtime.py +0 -152
  29. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/.github/workflows/publish.yml +0 -0
  30. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/.gitignore +0 -0
  31. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/CONTRIBUTING.md +0 -0
  32. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/LICENSE +0 -0
  33. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/http-client.md +0 -0
  34. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/opencode-config.md +0 -0
  35. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/sessions.md +0 -0
  36. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/streaming.md +0 -0
  37. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/docs/users-and-workspaces.md +0 -0
  38. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/py.typed +0 -0
  39. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/src/opencode_runtime/response.py +0 -0
  40. {opencode_runtime-0.4.1 → opencode_runtime-0.5.0}/tests/test.py +0 -0
@@ -1,8 +1,10 @@
1
1
  name: ci
2
2
 
3
3
  on:
4
- push:
5
4
  pull_request:
5
+ push:
6
+ branches:
7
+ - main
6
8
 
7
9
  jobs:
8
10
  ci:
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
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.4.1
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 UPTIME WORKSPACE USER PROJECT
95
- ──────────────────────────────────────────────────────────────────────────────────
96
- 39dce5beb4debfaa 12051 58409 ● alive Up 5m org_a u_1 ~/Developer/myproject
97
- 81fa29acb3e9210f 12088 58411 ● alive Up 3m org_b u_2 ~/Developer/myproject
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 UPTIME WORKSPACE USER PROJECT
62
- ──────────────────────────────────────────────────────────────────────────────────
63
- 39dce5beb4debfaa 12051 58409 ● alive Up 5m org_a u_1 ~/Developer/myproject
64
- 81fa29acb3e9210f 12088 58411 ● alive Up 3m org_b u_2 ~/Developer/myproject
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 UPTIME WORKSPACE USER PROJECT
15
- ──────────────────────────────────────────────────────────────────────────────────
16
- 39dce5beb4debfaa 12051 58409 ● alive Up 5m org_a u_1 ~/Developer/myproject
17
- 81fa29acb3e9210f 12088 58411 ● alive Up 3m org_b u_2 ~/Developer/myproject
18
- c3f2a1d9e8b74f05 13204 58413 ● alive Up 1h org_c u_3 ~/Developer/myproject
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 |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opencode-runtime"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Embed OpenCode in your Python application."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,11 +2,11 @@
2
2
  opencode-runtime: runtime infrastructure for multi-user OpenCode deployments.
3
3
  """
4
4
 
5
- __version__ = "0.4.1"
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"\033[2m{s}{_R}"
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
- entries = ServerManager().list()
156
- show_workspace = any(e.workspace for e, _ in entries)
157
- show_user = any(e.user_id for e, _ in entries)
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}", "{:<7}", "{:>8}"]
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 e, alive in entries:
175
- status_plain = "● alive" if alive else "● dead"
176
- status_coloured = _green(status_plain) if alive else _red(status_plain)
177
- vals = [e.key, str(e.pid), str(e.port), status_plain, _uptime(e.started_at, alive)]
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
- row = row.replace(status_plain, status_coloured, 1)
185
- print(_dim(row).replace(_dim(status_coloured), status_coloured, 1))
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 = manager.find(args.key)
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
- from .exceptions import OpenCodeServerError
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
- entry = manager.find(args.key)
237
- if entry is None:
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
- url = f"http://127.0.0.1:{entry.port}"
324
+ # Compute uptime
241
325
  try:
242
- result = asyncio.run(manager.health(args.key))
243
- version = result.get("version")
244
- print(_green("✓ healthy") + f" {_dim(f'version {version}')}" + f" {_dim(url)}")
245
- except OpenCodeServerError as exc:
246
- sys.exit(_red(f"✗ unreachable {url}\n {exc}"))
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: Bearer <password>`` when set.
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
 
@@ -20,8 +20,3 @@ class OpenCodeEvent:
20
20
  type: str
21
21
  text: str | None = None
22
22
  raw: Any = None
23
-
24
- def to_sse(self) -> str:
25
- """Format as a Server-Sent Events string suitable for HTTP streaming."""
26
- data = self.text or ""
27
- return f"event: {self.type}\ndata: {data}\n\n"
@@ -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")