opencode-runtime 0.4.1__py3-none-any.whl → 0.5.0__py3-none-any.whl
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/__init__.py +2 -2
- opencode_runtime/cli.py +151 -23
- opencode_runtime/client.py +3 -2
- opencode_runtime/event.py +0 -5
- opencode_runtime/exceptions.py +8 -0
- opencode_runtime/registry.py +139 -35
- opencode_runtime/runtime.py +12 -8
- opencode_runtime/schema.py +79 -0
- opencode_runtime/server.py +254 -99
- opencode_runtime/session.py +2 -2
- {opencode_runtime-0.4.1.dist-info → opencode_runtime-0.5.0.dist-info}/METADATA +6 -5
- opencode_runtime-0.5.0.dist-info/RECORD +17 -0
- opencode_runtime-0.4.1.dist-info/RECORD +0 -16
- {opencode_runtime-0.4.1.dist-info → opencode_runtime-0.5.0.dist-info}/WHEEL +0 -0
- {opencode_runtime-0.4.1.dist-info → opencode_runtime-0.5.0.dist-info}/entry_points.txt +0 -0
- {opencode_runtime-0.4.1.dist-info → opencode_runtime-0.5.0.dist-info}/licenses/LICENSE +0 -0
opencode_runtime/__init__.py
CHANGED
|
@@ -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__ = [
|
opencode_runtime/cli.py
CHANGED
|
@@ -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)
|
opencode_runtime/client.py
CHANGED
|
@@ -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
|
|
opencode_runtime/event.py
CHANGED
opencode_runtime/exceptions.py
CHANGED
|
@@ -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
|
+
"""
|
opencode_runtime/registry.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Registry for tracking OpenCode instance processes.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
~/.opencode-runtime/servers
|
|
4
|
+
Every running instance is a row in a SQLite database at:
|
|
5
|
+
~/.opencode-runtime/servers/registry.db
|
|
6
6
|
|
|
7
7
|
Used by both the CLI (opencode-runtime serve/ps/stop) and the library
|
|
8
8
|
(OpenCodeRuntime) — the registry is the shared source of truth for all
|
|
@@ -11,67 +11,166 @@ running instances regardless of how they were started.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
-
import json
|
|
15
14
|
import os
|
|
15
|
+
import sqlite3
|
|
16
|
+
from contextlib import contextmanager
|
|
16
17
|
from dataclasses import asdict, dataclass
|
|
17
|
-
from datetime import datetime, timezone
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from enum import Enum
|
|
18
20
|
from pathlib import Path
|
|
21
|
+
from typing import Generator
|
|
19
22
|
|
|
20
|
-
|
|
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"
|
|
21
52
|
|
|
22
53
|
|
|
23
54
|
@dataclass
|
|
24
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
|
+
|
|
25
61
|
key: str
|
|
26
|
-
|
|
62
|
+
state: ServerState
|
|
63
|
+
pid: int | None
|
|
27
64
|
port: int
|
|
28
65
|
password: str
|
|
29
66
|
project_dir: str
|
|
30
67
|
server_dir: str | None
|
|
31
68
|
started_at: str # ISO-8601
|
|
69
|
+
claimed_at: str # ISO-8601; only meaningful while state == "starting"
|
|
32
70
|
workspace: str | None = None
|
|
33
71
|
user_id: str | None = None
|
|
72
|
+
last_used_at: str | None = None
|
|
73
|
+
runtime_version: str | None = None
|
|
34
74
|
|
|
35
75
|
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
"""
|
|
38
96
|
REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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))
|
|
42
125
|
|
|
43
126
|
|
|
44
127
|
def read(key: str) -> RegistryEntry | None:
|
|
45
128
|
"""Read a registry entry by key. Returns None if not found."""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
-
return RegistryEntry(**data)
|
|
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
|
|
51
132
|
|
|
52
133
|
|
|
53
134
|
def delete(key: str) -> None:
|
|
54
135
|
"""Remove a registry entry. No-op if not found."""
|
|
55
|
-
|
|
56
|
-
|
|
136
|
+
with _connect() as conn:
|
|
137
|
+
conn.execute("DELETE FROM servers WHERE key = ?", (key,))
|
|
57
138
|
|
|
58
139
|
|
|
59
140
|
def list_all() -> list[RegistryEntry]:
|
|
60
|
-
"""Return all registry entries
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
75
174
|
try:
|
|
76
175
|
os.kill(pid, 0)
|
|
77
176
|
return True
|
|
@@ -80,5 +179,10 @@ def is_alive(pid: int) -> bool:
|
|
|
80
179
|
|
|
81
180
|
|
|
82
181
|
def now_iso() -> str:
|
|
83
|
-
"""Return current UTC time as ISO-8601 string.
|
|
84
|
-
|
|
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")
|
opencode_runtime/runtime.py
CHANGED
|
@@ -55,19 +55,21 @@ class OpenCodeRuntime:
|
|
|
55
55
|
# ------------------------------------------------------------------
|
|
56
56
|
|
|
57
57
|
async def __aenter__(self) -> OpenCodeRuntime:
|
|
58
|
-
await self.start()
|
|
59
58
|
return self
|
|
60
59
|
|
|
61
60
|
async def __aexit__(self, exc_type: t.Any, exc: t.Any, tb: t.Any) -> None:
|
|
62
|
-
await self.
|
|
61
|
+
await self.close()
|
|
63
62
|
|
|
64
|
-
async def
|
|
65
|
-
"""
|
|
66
|
-
await self.session()
|
|
63
|
+
async def close(self) -> None:
|
|
64
|
+
"""Stop every server this runtime instance itself started.
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
Servers this runtime merely attached to — already running, started
|
|
67
|
+
by another process or the CLI — are left alone; they're shared
|
|
68
|
+
state and exiting this runtime shouldn't terminate them out from
|
|
69
|
+
under whoever else is using them. To stop those explicitly, use the
|
|
70
|
+
CLI (``opencode-runtime stop`` / ``stop-all``).
|
|
71
|
+
"""
|
|
72
|
+
await self._server_manager.stop_owned()
|
|
71
73
|
|
|
72
74
|
# ------------------------------------------------------------------
|
|
73
75
|
# Session factory
|
|
@@ -126,6 +128,8 @@ class OpenCodeRuntime:
|
|
|
126
128
|
user_id=user_id,
|
|
127
129
|
)
|
|
128
130
|
|
|
131
|
+
self._server_manager.touch(key)
|
|
132
|
+
|
|
129
133
|
return OpenCodeSession(
|
|
130
134
|
client=server.client,
|
|
131
135
|
workspace=workspace,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite schema and migration mechanism for the registry database.
|
|
3
|
+
|
|
4
|
+
The schema version is stored in the database itself via SQLite's built-in
|
|
5
|
+
`PRAGMA user_version` — no separate metadata table needed. A fresh database
|
|
6
|
+
is stamped with SCHEMA_VERSION directly; an existing one is brought up to it
|
|
7
|
+
by migrate().
|
|
8
|
+
|
|
9
|
+
Bump SCHEMA_VERSION and add a step to _MIGRATIONS whenever the `servers`
|
|
10
|
+
table shape changes. Never edit SCHEMA in place once a version has shipped —
|
|
11
|
+
existing databases on disk still have the old shape and rely on a migration
|
|
12
|
+
step to reach the new one.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sqlite3
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from .exceptions import RegistrySchemaError
|
|
21
|
+
|
|
22
|
+
SCHEMA = """
|
|
23
|
+
CREATE TABLE IF NOT EXISTS servers (
|
|
24
|
+
key TEXT PRIMARY KEY,
|
|
25
|
+
state TEXT NOT NULL,
|
|
26
|
+
pid INTEGER,
|
|
27
|
+
port INTEGER NOT NULL,
|
|
28
|
+
password TEXT NOT NULL,
|
|
29
|
+
project_dir TEXT NOT NULL,
|
|
30
|
+
server_dir TEXT,
|
|
31
|
+
started_at TEXT NOT NULL,
|
|
32
|
+
claimed_at TEXT NOT NULL,
|
|
33
|
+
workspace TEXT,
|
|
34
|
+
user_id TEXT,
|
|
35
|
+
last_used_at TEXT,
|
|
36
|
+
runtime_version TEXT
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
SCHEMA_VERSION = 1
|
|
41
|
+
|
|
42
|
+
# Migration steps, keyed by the version they upgrade *from*. Each callable
|
|
43
|
+
# receives the open connection (mid-transaction, table already created) and
|
|
44
|
+
# must leave the database in the shape of `from_version + 1` — e.g. an
|
|
45
|
+
# `ALTER TABLE servers ADD COLUMN ...`.
|
|
46
|
+
#
|
|
47
|
+
# Version 0 covers every database that predates this versioning scheme. Its
|
|
48
|
+
# table shape is identical to version 1 (versioning was introduced with no
|
|
49
|
+
# accompanying column change), so that migration is a no-op — it exists so
|
|
50
|
+
# unversioned databases get stamped with a version the first time they're
|
|
51
|
+
# opened under this scheme.
|
|
52
|
+
_MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {
|
|
53
|
+
0: lambda conn: None,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def migrate(conn: sqlite3.Connection, current_version: int) -> None:
|
|
58
|
+
"""Bring conn's schema from current_version up to SCHEMA_VERSION, in place.
|
|
59
|
+
|
|
60
|
+
Raises RegistrySchemaError if current_version is newer than this code
|
|
61
|
+
understands, or if a required migration step isn't registered.
|
|
62
|
+
"""
|
|
63
|
+
if current_version > SCHEMA_VERSION:
|
|
64
|
+
raise RegistrySchemaError(
|
|
65
|
+
f"registry database is schema v{current_version}, but this version of "
|
|
66
|
+
f"opencode-runtime only understands up to v{SCHEMA_VERSION}. Upgrade "
|
|
67
|
+
"opencode-runtime to use it."
|
|
68
|
+
)
|
|
69
|
+
version = current_version
|
|
70
|
+
while version < SCHEMA_VERSION:
|
|
71
|
+
step = _MIGRATIONS.get(version)
|
|
72
|
+
if step is None:
|
|
73
|
+
raise RegistrySchemaError(
|
|
74
|
+
f"no migration registered to bring the registry database from schema "
|
|
75
|
+
f"v{version} to v{version + 1}"
|
|
76
|
+
)
|
|
77
|
+
step(conn)
|
|
78
|
+
version += 1
|
|
79
|
+
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
opencode_runtime/server.py
CHANGED
|
@@ -13,15 +13,34 @@ import json
|
|
|
13
13
|
import os
|
|
14
14
|
import secrets
|
|
15
15
|
import shutil
|
|
16
|
+
import signal
|
|
16
17
|
import socket
|
|
17
18
|
from dataclasses import dataclass
|
|
19
|
+
from enum import Enum
|
|
18
20
|
from pathlib import Path
|
|
19
|
-
from typing import
|
|
21
|
+
from typing import Any
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
from .
|
|
23
|
+
try:
|
|
24
|
+
from importlib.metadata import version
|
|
25
|
+
except ImportError:
|
|
26
|
+
from importlib_metadata import version # type: ignore[import-not-found,no-redef]
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from . import registry
|
|
31
|
+
from .client import OpenCodeClient
|
|
32
|
+
from .exceptions import (
|
|
33
|
+
OpenCodeNotFoundError,
|
|
34
|
+
OpenCodeRuntimeError,
|
|
35
|
+
OpenCodeServerError,
|
|
36
|
+
OpenCodeTimeoutError,
|
|
37
|
+
)
|
|
38
|
+
from .registry import RegistryEntry, ServerState
|
|
39
|
+
|
|
40
|
+
# One or more paths to overlay into the server dir before startup.
|
|
41
|
+
# Module-level alias: inside ServerManager's class body, `list[...]` in an
|
|
42
|
+
# annotation would resolve to the ServerManager.list method, not the builtin.
|
|
43
|
+
Materials = str | Path | list[str | Path] | None
|
|
25
44
|
|
|
26
45
|
|
|
27
46
|
@dataclass
|
|
@@ -34,6 +53,58 @@ class _ManagedServer:
|
|
|
34
53
|
server_dir: Path | None # None when runtime_dir is not set (no isolation)
|
|
35
54
|
|
|
36
55
|
|
|
56
|
+
class DisplayStatus(str, Enum):
|
|
57
|
+
"""User-facing status derived from registry state, process liveness, and health."""
|
|
58
|
+
|
|
59
|
+
STARTING = "starting"
|
|
60
|
+
RUNNING = "running"
|
|
61
|
+
UNHEALTHY = "unhealthy"
|
|
62
|
+
STALE = "stale"
|
|
63
|
+
FAILED = "failed"
|
|
64
|
+
STOPPING = "stopping"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ServerStatus:
|
|
69
|
+
"""Computed liveness/health status for a registry entry."""
|
|
70
|
+
|
|
71
|
+
entry: RegistryEntry
|
|
72
|
+
process_alive: bool
|
|
73
|
+
health_ok: bool
|
|
74
|
+
display: DisplayStatus
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_process_alive(pid: int | None) -> bool:
|
|
78
|
+
"""Return True if pid is set and a process with it is running."""
|
|
79
|
+
return registry.is_alive(pid)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _is_health_ok(client: OpenCodeClient, timeout: float = 3.0) -> bool:
|
|
83
|
+
"""Return True if /global/health endpoint responds successfully."""
|
|
84
|
+
try:
|
|
85
|
+
await asyncio.wait_for(client.health(), timeout=timeout)
|
|
86
|
+
return True
|
|
87
|
+
except Exception:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _compute_display_status(
|
|
92
|
+
state: ServerState, process_alive: bool, health_ok: bool, lease_expired: bool = False
|
|
93
|
+
) -> DisplayStatus:
|
|
94
|
+
"""Derive user-facing display status from state, process liveness, and health."""
|
|
95
|
+
if state == ServerState.STARTING:
|
|
96
|
+
return DisplayStatus.FAILED if lease_expired else DisplayStatus.STARTING
|
|
97
|
+
if state == ServerState.STOPPING:
|
|
98
|
+
return DisplayStatus.STOPPING
|
|
99
|
+
if state == ServerState.FAILED:
|
|
100
|
+
return DisplayStatus.FAILED
|
|
101
|
+
if not process_alive:
|
|
102
|
+
return DisplayStatus.STALE
|
|
103
|
+
if not health_ok:
|
|
104
|
+
return DisplayStatus.UNHEALTHY
|
|
105
|
+
return DisplayStatus.RUNNING
|
|
106
|
+
|
|
107
|
+
|
|
37
108
|
def _find_free_port(host: str = "127.0.0.1") -> int:
|
|
38
109
|
"""Bind to port 0 and let the OS pick a free ephemeral port."""
|
|
39
110
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
@@ -47,8 +118,6 @@ async def _wait_healthy(
|
|
|
47
118
|
process: asyncio.subprocess.Process | None = None,
|
|
48
119
|
) -> None:
|
|
49
120
|
"""Poll GET /global/health until the server responds or timeout expires."""
|
|
50
|
-
from .exceptions import OpenCodeTimeoutError
|
|
51
|
-
|
|
52
121
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
53
122
|
last_exc: Exception | None = None
|
|
54
123
|
|
|
@@ -70,11 +139,9 @@ async def _wait_healthy(
|
|
|
70
139
|
def _prepare_dir(
|
|
71
140
|
server_dir: Path,
|
|
72
141
|
config: dict[str, Any],
|
|
73
|
-
materials:
|
|
142
|
+
materials: Materials,
|
|
74
143
|
) -> None:
|
|
75
144
|
"""Write opencode.json and overlay materials into server_dir."""
|
|
76
|
-
from .exceptions import OpenCodeRuntimeError
|
|
77
|
-
|
|
78
145
|
if config:
|
|
79
146
|
(server_dir / "opencode.json").write_text(
|
|
80
147
|
json.dumps(config, indent=2),
|
|
@@ -119,7 +186,7 @@ def _compute_runtime_key(
|
|
|
119
186
|
workspace: str | None,
|
|
120
187
|
user_id: str | None,
|
|
121
188
|
project_dir: Path,
|
|
122
|
-
materials:
|
|
189
|
+
materials: Materials,
|
|
123
190
|
config: dict[str, Any],
|
|
124
191
|
) -> str:
|
|
125
192
|
"""Compute a stable 16-char key for a unique server configuration.
|
|
@@ -152,31 +219,82 @@ class ServerManager:
|
|
|
152
219
|
and config gets its own isolated OpenCode instance. Instances are started
|
|
153
220
|
on demand and reused when the same key is requested again.
|
|
154
221
|
|
|
155
|
-
The registry is the single source of truth
|
|
156
|
-
|
|
157
|
-
another process) are always reflected
|
|
222
|
+
The registry is the single source of truth for server state — there is
|
|
223
|
+
no in-memory cache of it, so every call consults the registry and
|
|
224
|
+
external actors (CLI stop-all, another process) are always reflected
|
|
225
|
+
immediately. The one piece of instance state this class keeps is
|
|
226
|
+
_owned — which keys *this instance* actually spawned via get_or_start(),
|
|
227
|
+
as opposed to attaching to a server already running elsewhere — so that
|
|
228
|
+
callers like OpenCodeRuntime.close() can stop what they started without
|
|
229
|
+
touching servers owned by someone else.
|
|
158
230
|
"""
|
|
159
231
|
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
from .registry import read as registry_read
|
|
232
|
+
def __init__(self) -> None:
|
|
233
|
+
self._owned: set[str] = set()
|
|
163
234
|
|
|
164
|
-
|
|
235
|
+
def find(self, key: str) -> RegistryEntry | None:
|
|
236
|
+
"""Return the registry entry for key, or None if not found or still starting."""
|
|
237
|
+
entry = registry.read(key)
|
|
238
|
+
return entry if entry is not None and entry.state == ServerState.RUNNING else None
|
|
165
239
|
|
|
166
240
|
def is_alive(self, key: str) -> bool:
|
|
167
241
|
"""Return True if the server for key is running."""
|
|
168
|
-
|
|
169
|
-
|
|
242
|
+
entry = self.find(key)
|
|
243
|
+
return entry is not None and registry.is_alive(entry.pid)
|
|
244
|
+
|
|
245
|
+
def touch(self, key: str) -> None:
|
|
246
|
+
"""Update last_used_at timestamp for a server. Call after session creation."""
|
|
247
|
+
entry = registry.read(key)
|
|
248
|
+
if entry is not None and entry.state == ServerState.RUNNING:
|
|
249
|
+
entry.last_used_at = registry.now_iso()
|
|
250
|
+
registry.write(entry)
|
|
251
|
+
|
|
252
|
+
async def _status_for_entry(
|
|
253
|
+
self, entry: RegistryEntry, *, health_timeout: float = 3.0
|
|
254
|
+
) -> ServerStatus:
|
|
255
|
+
"""Derive a ServerStatus for an already-fetched registry entry."""
|
|
256
|
+
process_alive = (
|
|
257
|
+
_is_process_alive(entry.pid) if entry.state == ServerState.RUNNING else False
|
|
258
|
+
)
|
|
259
|
+
health_ok = False
|
|
260
|
+
if process_alive:
|
|
261
|
+
client = OpenCodeClient(
|
|
262
|
+
base_url=f"http://127.0.0.1:{entry.port}",
|
|
263
|
+
password=entry.password,
|
|
264
|
+
)
|
|
265
|
+
health_ok = await _is_health_ok(client, timeout=health_timeout)
|
|
266
|
+
display = _compute_display_status(entry.state, process_alive, health_ok)
|
|
267
|
+
return ServerStatus(
|
|
268
|
+
entry=entry, process_alive=process_alive, health_ok=health_ok, display=display
|
|
269
|
+
)
|
|
170
270
|
|
|
171
|
-
|
|
172
|
-
|
|
271
|
+
async def status(self, key: str, *, health_timeout: float = 3.0) -> ServerStatus | None:
|
|
272
|
+
"""Return the computed status for key, or None if not in the registry."""
|
|
273
|
+
entry = registry.read(key)
|
|
274
|
+
if entry is None:
|
|
275
|
+
return None
|
|
276
|
+
return await self._status_for_entry(entry, health_timeout=health_timeout)
|
|
277
|
+
|
|
278
|
+
# NOTE: list() and list_statuses() must stay defined in this order — once
|
|
279
|
+
# `list` is bound as a class attribute (the method below), any later
|
|
280
|
+
# annotation in this class body that writes the bare name `list[...]`
|
|
281
|
+
# resolves to that method, not the builtin (see the Materials alias note
|
|
282
|
+
# up top for the same issue). list_statuses()'s `-> list[ServerStatus]`
|
|
283
|
+
# return annotation is defined above list() to avoid it.
|
|
284
|
+
async def list_statuses(self, *, health_timeout: float = 1.0) -> list[ServerStatus]:
|
|
285
|
+
"""Return computed status for every ready registry entry."""
|
|
286
|
+
return [
|
|
287
|
+
await self._status_for_entry(entry, health_timeout=health_timeout)
|
|
288
|
+
for entry, _ in self.list()
|
|
289
|
+
]
|
|
173
290
|
|
|
174
291
|
def list(self) -> list[tuple[RegistryEntry, bool]]:
|
|
175
|
-
"""Return all registry entries with their liveness status."""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
292
|
+
"""Return all ready registry entries with their liveness status."""
|
|
293
|
+
return [
|
|
294
|
+
(entry, registry.is_alive(entry.pid))
|
|
295
|
+
for entry in registry.list_all()
|
|
296
|
+
if entry.state == ServerState.RUNNING
|
|
297
|
+
]
|
|
180
298
|
|
|
181
299
|
async def health(self, key: str) -> dict[str, Any]:
|
|
182
300
|
"""Return health info for the server at key.
|
|
@@ -184,13 +302,7 @@ class ServerManager:
|
|
|
184
302
|
Raises ``OpenCodeServerError`` if key is not in the registry or the
|
|
185
303
|
server is unreachable.
|
|
186
304
|
"""
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
from .client import OpenCodeClient
|
|
190
|
-
from .exceptions import OpenCodeServerError
|
|
191
|
-
from .registry import read as registry_read
|
|
192
|
-
|
|
193
|
-
entry = registry_read(key)
|
|
305
|
+
entry = self.find(key)
|
|
194
306
|
if entry is None:
|
|
195
307
|
raise OpenCodeServerError(f"server {key!r} not found in registry")
|
|
196
308
|
|
|
@@ -209,21 +321,15 @@ class ServerManager:
|
|
|
209
321
|
Returns True if the process was alive and killed, False if it was
|
|
210
322
|
already dead or not found in the registry.
|
|
211
323
|
"""
|
|
212
|
-
|
|
213
|
-
from .registry import is_alive
|
|
214
|
-
from .registry import read as registry_read
|
|
215
|
-
|
|
216
|
-
entry = registry_read(key)
|
|
324
|
+
entry = registry.read(key)
|
|
217
325
|
if entry is None:
|
|
218
326
|
return False
|
|
219
327
|
|
|
220
|
-
|
|
328
|
+
registry.delete(key)
|
|
221
329
|
|
|
222
|
-
if not is_alive(entry.pid):
|
|
330
|
+
if entry.pid is None or not registry.is_alive(entry.pid):
|
|
223
331
|
return False
|
|
224
332
|
|
|
225
|
-
import signal
|
|
226
|
-
|
|
227
333
|
try:
|
|
228
334
|
os.kill(entry.pid, signal.SIGTERM)
|
|
229
335
|
except ProcessLookupError:
|
|
@@ -231,7 +337,7 @@ class ServerManager:
|
|
|
231
337
|
# Wait for the process to exit (up to 5s, then SIGKILL).
|
|
232
338
|
deadline = asyncio.get_event_loop().time() + 5.0
|
|
233
339
|
while asyncio.get_event_loop().time() < deadline:
|
|
234
|
-
if not is_alive(entry.pid):
|
|
340
|
+
if not registry.is_alive(entry.pid):
|
|
235
341
|
return True
|
|
236
342
|
await asyncio.sleep(0.1)
|
|
237
343
|
try:
|
|
@@ -242,54 +348,112 @@ class ServerManager:
|
|
|
242
348
|
|
|
243
349
|
async def stop_all(self) -> None:
|
|
244
350
|
"""Kill all servers tracked in the registry."""
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
for entry in list_all():
|
|
351
|
+
for entry in registry.list_all():
|
|
248
352
|
await self.stop(entry.key)
|
|
249
353
|
|
|
354
|
+
async def stop_owned(self) -> None:
|
|
355
|
+
"""Stop every server this instance itself spawned via get_or_start().
|
|
356
|
+
|
|
357
|
+
Servers this instance merely attached to — already running, started
|
|
358
|
+
by another process or the CLI — are left alone.
|
|
359
|
+
"""
|
|
360
|
+
for key in list(self._owned):
|
|
361
|
+
await self.stop(key)
|
|
362
|
+
self._owned.clear()
|
|
363
|
+
|
|
250
364
|
async def get_or_start(
|
|
251
365
|
self,
|
|
252
366
|
*,
|
|
253
367
|
key: str,
|
|
254
368
|
project_dir: Path,
|
|
255
369
|
server_dir: Path | None,
|
|
256
|
-
materials:
|
|
370
|
+
materials: Materials,
|
|
257
371
|
config: dict[str, Any],
|
|
258
372
|
env: dict[str, str],
|
|
259
373
|
workspace: str | None = None,
|
|
260
374
|
user_id: str | None = None,
|
|
261
375
|
) -> _ManagedServer:
|
|
262
376
|
"""Return a client for the running server, starting one if needed."""
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
377
|
+
entry = registry.read(key)
|
|
378
|
+
if entry is not None and entry.state == ServerState.RUNNING:
|
|
379
|
+
if registry.is_alive(entry.pid):
|
|
380
|
+
return self._attach(entry)
|
|
381
|
+
# Stale — the process died after finishing startup.
|
|
382
|
+
registry.delete(key)
|
|
383
|
+
|
|
384
|
+
# port/password are generated here (not inside _start) because a
|
|
385
|
+
# 'starting' claim row needs both up front — pid is the only field
|
|
386
|
+
# that isn't known until the subprocess actually exists.
|
|
387
|
+
port = _find_free_port()
|
|
388
|
+
password = secrets.token_urlsafe(32)
|
|
389
|
+
timestamp = registry.now_iso()
|
|
390
|
+
|
|
391
|
+
claimed = registry.claim_starting(
|
|
392
|
+
RegistryEntry(
|
|
271
393
|
key=key,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
),
|
|
277
|
-
server_dir=
|
|
394
|
+
state=ServerState.STARTING,
|
|
395
|
+
pid=None,
|
|
396
|
+
port=port,
|
|
397
|
+
password=password,
|
|
398
|
+
project_dir=str(project_dir),
|
|
399
|
+
server_dir=str(server_dir) if server_dir else None,
|
|
400
|
+
started_at=timestamp,
|
|
401
|
+
claimed_at=timestamp,
|
|
402
|
+
workspace=workspace,
|
|
403
|
+
user_id=user_id,
|
|
278
404
|
)
|
|
405
|
+
)
|
|
406
|
+
if not claimed:
|
|
407
|
+
# Another caller is already starting this key — wait for it
|
|
408
|
+
# instead of racing to spawn a second process for the same key.
|
|
409
|
+
return await self._wait_for_ready(key)
|
|
279
410
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
411
|
+
try:
|
|
412
|
+
server = await self._start(
|
|
413
|
+
key=key,
|
|
414
|
+
project_dir=project_dir,
|
|
415
|
+
server_dir=server_dir,
|
|
416
|
+
materials=materials,
|
|
417
|
+
config=config,
|
|
418
|
+
env=env,
|
|
419
|
+
port=port,
|
|
420
|
+
password=password,
|
|
421
|
+
workspace=workspace,
|
|
422
|
+
user_id=user_id,
|
|
423
|
+
)
|
|
424
|
+
except Exception:
|
|
425
|
+
registry.delete(key)
|
|
426
|
+
raise
|
|
427
|
+
self._owned.add(key)
|
|
428
|
+
return server
|
|
283
429
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
430
|
+
def _attach(self, entry: RegistryEntry) -> _ManagedServer:
|
|
431
|
+
"""Build a _ManagedServer client for an already-running registry entry."""
|
|
432
|
+
return _ManagedServer(
|
|
433
|
+
key=entry.key,
|
|
434
|
+
process=None,
|
|
435
|
+
client=OpenCodeClient(
|
|
436
|
+
base_url=f"http://127.0.0.1:{entry.port}",
|
|
437
|
+
password=entry.password,
|
|
438
|
+
),
|
|
439
|
+
server_dir=Path(entry.server_dir) if entry.server_dir else None,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
async def _wait_for_ready(self, key: str, timeout: float = 60.0) -> _ManagedServer:
|
|
443
|
+
"""Poll the registry until the caller holding the start-claim finishes."""
|
|
444
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
445
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
446
|
+
entry = registry.read(key)
|
|
447
|
+
if entry is None:
|
|
448
|
+
raise OpenCodeServerError(
|
|
449
|
+
f"the caller starting server {key!r} failed before it became ready"
|
|
450
|
+
)
|
|
451
|
+
if entry.state == ServerState.RUNNING and registry.is_alive(entry.pid):
|
|
452
|
+
return self._attach(entry)
|
|
453
|
+
await asyncio.sleep(0.1)
|
|
454
|
+
|
|
455
|
+
raise OpenCodeTimeoutError(
|
|
456
|
+
f"timed out waiting for another caller to start the server for key {key!r}"
|
|
293
457
|
)
|
|
294
458
|
|
|
295
459
|
async def _start(
|
|
@@ -298,44 +462,33 @@ class ServerManager:
|
|
|
298
462
|
key: str,
|
|
299
463
|
project_dir: Path,
|
|
300
464
|
server_dir: Path | None,
|
|
301
|
-
materials:
|
|
465
|
+
materials: Materials,
|
|
302
466
|
config: dict[str, Any],
|
|
303
467
|
env: dict[str, str],
|
|
468
|
+
port: int,
|
|
469
|
+
password: str,
|
|
304
470
|
workspace: str | None = None,
|
|
305
471
|
user_id: str | None = None,
|
|
306
472
|
) -> _ManagedServer:
|
|
307
473
|
"""Start a new OpenCode instance and return a _ManagedServer."""
|
|
308
|
-
from .client import OpenCodeClient
|
|
309
|
-
from .exceptions import OpenCodeNotFoundError
|
|
310
|
-
|
|
311
474
|
if shutil.which("opencode") is None:
|
|
312
475
|
raise OpenCodeNotFoundError(
|
|
313
476
|
"opencode binary not found on PATH. Install it with: npm install -g opencode-ai"
|
|
314
477
|
)
|
|
315
478
|
|
|
316
|
-
if server_dir is not None:
|
|
317
|
-
server_dir.mkdir(parents=True, exist_ok=True)
|
|
318
|
-
(server_dir / "tmp").mkdir(exist_ok=True)
|
|
319
|
-
_prepare_dir(server_dir, config, materials)
|
|
320
|
-
|
|
321
|
-
port = _find_free_port()
|
|
322
|
-
password = secrets.token_urlsafe(32)
|
|
323
|
-
|
|
324
479
|
process_env = {**os.environ, **env}
|
|
325
480
|
process_env["OPENCODE_SERVER_PASSWORD"] = password
|
|
326
481
|
|
|
327
482
|
if server_dir is not None:
|
|
483
|
+
server_dir.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
(server_dir / "tmp").mkdir(exist_ok=True)
|
|
485
|
+
_prepare_dir(server_dir, config, materials)
|
|
328
486
|
process_env["HOME"] = str(server_dir)
|
|
329
487
|
process_env["TMPDIR"] = str(server_dir / "tmp")
|
|
330
488
|
process_env["OPENCODE_CONFIG"] = str(server_dir / "opencode.json")
|
|
331
|
-
|
|
332
|
-
if server_dir is not None:
|
|
333
|
-
log_file = open(server_dir / "opencode.log", "ab")
|
|
334
|
-
stdout = log_file
|
|
335
|
-
stderr = log_file
|
|
489
|
+
output: Any = open(server_dir / "opencode.log", "ab")
|
|
336
490
|
else:
|
|
337
|
-
|
|
338
|
-
stderr = asyncio.subprocess.DEVNULL
|
|
491
|
+
output = asyncio.subprocess.DEVNULL
|
|
339
492
|
|
|
340
493
|
process = await asyncio.create_subprocess_exec(
|
|
341
494
|
"opencode",
|
|
@@ -346,8 +499,8 @@ class ServerManager:
|
|
|
346
499
|
str(port),
|
|
347
500
|
cwd=str(project_dir),
|
|
348
501
|
env=process_env,
|
|
349
|
-
stdout=
|
|
350
|
-
stderr=
|
|
502
|
+
stdout=output,
|
|
503
|
+
stderr=output,
|
|
351
504
|
)
|
|
352
505
|
|
|
353
506
|
client = OpenCodeClient(
|
|
@@ -369,18 +522,20 @@ class ServerManager:
|
|
|
369
522
|
await _terminate_process(process)
|
|
370
523
|
raise
|
|
371
524
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
registry_write(
|
|
525
|
+
timestamp = registry.now_iso()
|
|
526
|
+
registry.write(
|
|
376
527
|
RegistryEntry(
|
|
377
528
|
key=key,
|
|
529
|
+
state=ServerState.RUNNING,
|
|
378
530
|
pid=process.pid,
|
|
379
531
|
port=port,
|
|
380
532
|
password=password,
|
|
381
533
|
project_dir=str(project_dir),
|
|
382
534
|
server_dir=str(server_dir) if server_dir else None,
|
|
383
|
-
started_at=
|
|
535
|
+
started_at=timestamp,
|
|
536
|
+
claimed_at=timestamp,
|
|
537
|
+
last_used_at=timestamp,
|
|
538
|
+
runtime_version=version("opencode-runtime"),
|
|
384
539
|
workspace=workspace,
|
|
385
540
|
user_id=user_id,
|
|
386
541
|
)
|
opencode_runtime/session.py
CHANGED
|
@@ -147,6 +147,6 @@ class OpenCodeSession:
|
|
|
147
147
|
if self.session_id is not None:
|
|
148
148
|
await self._client.post(f"/session/{self.session_id}/abort", {})
|
|
149
149
|
|
|
150
|
-
async def
|
|
151
|
-
"""
|
|
150
|
+
async def reset(self) -> None:
|
|
151
|
+
"""Forget the current conversation. The next ask()/stream() call starts a new one."""
|
|
152
152
|
self.session_id = None
|
|
@@ -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
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
opencode_runtime/__init__.py,sha256=fK91fH2ELtCTVAlMQet6T1N8A4P9phbocbZvKJVXpRI,362
|
|
2
|
+
opencode_runtime/cli.py,sha256=tRrDaolRx_EEByAeWiIHaw7i-HVtCkFR_hTMnQsvIiI,13446
|
|
3
|
+
opencode_runtime/client.py,sha256=EJ9bRsItC8_AdeYLSXTLNllEjlo5xlbLc6jsAuv7EcE,9124
|
|
4
|
+
opencode_runtime/event.py,sha256=6-mIAGEURP-sfj2zGABe1O8h9Phd2ao0iqmDOr1PDGI,763
|
|
5
|
+
opencode_runtime/exceptions.py,sha256=d6X7SFVnzUM_OHNzAuJ_qTkhRID1yNPchIaTepYGD4o,753
|
|
6
|
+
opencode_runtime/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
opencode_runtime/registry.py,sha256=-tymxyEM6bd6KHLB5Gao5lav83hnPB5NtUlu8I-q6WQ,6292
|
|
8
|
+
opencode_runtime/response.py,sha256=MmT4qiakWD55J8cFg64VOztusfWkynrucdjbm0srNks,541
|
|
9
|
+
opencode_runtime/runtime.py,sha256=4b4c-cdZyUvTfy-GoWJwvgowm5wQCucP5llAV_dSSts,5488
|
|
10
|
+
opencode_runtime/schema.py,sha256=xPTKUKdgW9Oo0YqNBsO4vqIZ4fwJFBbxnt7iv1u3bSE,2889
|
|
11
|
+
opencode_runtime/server.py,sha256=DKVaJk29peJ0QsqfQBNnzPOVygBwQetM4rmTVCJra4s,19139
|
|
12
|
+
opencode_runtime/session.py,sha256=wUNgh6q12_ZqHitlPVJ-axRxK6XL30ll4nvwqFHRXXw,5819
|
|
13
|
+
opencode_runtime-0.5.0.dist-info/METADATA,sha256=HcCuN-_IT3sRAWE_Gt6XBzjHIgLQyWDa7zvmhLkl9tI,4227
|
|
14
|
+
opencode_runtime-0.5.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
15
|
+
opencode_runtime-0.5.0.dist-info/entry_points.txt,sha256=4lbdu1KELOg8TD6F7gC-Xrt3VJXdk3RTaH8cQHVy-Tc,63
|
|
16
|
+
opencode_runtime-0.5.0.dist-info/licenses/LICENSE,sha256=EVYfwpsynn_pB9fQ0OhMX-ghTZM10CrFAPv1JbLODi4,11343
|
|
17
|
+
opencode_runtime-0.5.0.dist-info/RECORD,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
opencode_runtime/__init__.py,sha256=RNZO_09NXusYD_xhbeJLb2HCp_0JeogNEi6247mPcL4,362
|
|
2
|
-
opencode_runtime/cli.py,sha256=byy2ezufQvk5f_e1nwDN8ZIFflIwgvuC9SX7HQGYTkU,8938
|
|
3
|
-
opencode_runtime/client.py,sha256=XZw413XiTOaAeKY5WufmMFsDAlullel50i_N5A0wYyM,9069
|
|
4
|
-
opencode_runtime/event.py,sha256=7tWTLXTVUB6g3Ni7jiG2VYdHhjU9rpiwrY0MT1RnEMQ,960
|
|
5
|
-
opencode_runtime/exceptions.py,sha256=uarBFCh8ovI3w4ZB4oQuffv97-5nwTdkZQrkBcOWtWs,471
|
|
6
|
-
opencode_runtime/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
opencode_runtime/registry.py,sha256=rxr3D4iqceskmPBYvRC_lbtME8hoMR4I6rsdYCqllLA,2309
|
|
8
|
-
opencode_runtime/response.py,sha256=MmT4qiakWD55J8cFg64VOztusfWkynrucdjbm0srNks,541
|
|
9
|
-
opencode_runtime/runtime.py,sha256=magKzC-kN7g9bsttNhSLEif9hD92_qrHgJgvXDOauYU,5255
|
|
10
|
-
opencode_runtime/server.py,sha256=eu2JwLwkpICRviZx4OmN2z2_gVWCkfnm4qhcQRW3bSw,12595
|
|
11
|
-
opencode_runtime/session.py,sha256=9ES_mVvXWFhLPNnFFY9KwvRi_4zoVNTe8EToU8oe3ng,5777
|
|
12
|
-
opencode_runtime-0.4.1.dist-info/METADATA,sha256=IkOu8zi4YGNidowr_OUYpypcvCYsq4R1-z8ul8-ocZc,4179
|
|
13
|
-
opencode_runtime-0.4.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
-
opencode_runtime-0.4.1.dist-info/entry_points.txt,sha256=4lbdu1KELOg8TD6F7gC-Xrt3VJXdk3RTaH8cQHVy-Tc,63
|
|
15
|
-
opencode_runtime-0.4.1.dist-info/licenses/LICENSE,sha256=EVYfwpsynn_pB9fQ0OhMX-ghTZM10CrFAPv1JbLODi4,11343
|
|
16
|
-
opencode_runtime-0.4.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|