opencode-runtime 0.4.0__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 +4 -7
- opencode_runtime/event.py +3 -7
- opencode_runtime/exceptions.py +8 -0
- opencode_runtime/registry.py +139 -35
- opencode_runtime/response.py +1 -1
- opencode_runtime/runtime.py +12 -8
- opencode_runtime/schema.py +79 -0
- opencode_runtime/server.py +255 -100
- opencode_runtime/session.py +6 -3
- opencode_runtime-0.5.0.dist-info/METADATA +126 -0
- opencode_runtime-0.5.0.dist-info/RECORD +17 -0
- {opencode_runtime-0.4.0.dist-info → opencode_runtime-0.5.0.dist-info}/WHEEL +1 -1
- opencode_runtime-0.4.0.dist-info/METADATA +0 -299
- opencode_runtime-0.4.0.dist-info/RECORD +0 -16
- {opencode_runtime-0.4.0.dist-info → opencode_runtime-0.5.0.dist-info}/entry_points.txt +0 -0
- {opencode_runtime-0.4.0.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
|
|
|
@@ -154,7 +155,7 @@ class OpenCodeClient:
|
|
|
154
155
|
delta string when ``properties.field == "text"``.
|
|
155
156
|
* ``message.part.updated`` — full part snapshot (text, tool, thinking,
|
|
156
157
|
…); ``event.text`` is the text content when ``part.type == "text"``.
|
|
157
|
-
* ``session.status`` — status change (e.g. ``{type: "
|
|
158
|
+
* ``session.status`` — status change (e.g. ``{type: "busy"}``).
|
|
158
159
|
* ``session.idle`` — terminal; model finished.
|
|
159
160
|
* ``session.error`` — terminal; something went wrong.
|
|
160
161
|
* ``permission.asked`` — tool permission request; caller must handle.
|
|
@@ -222,7 +223,3 @@ class OpenCodeClient:
|
|
|
222
223
|
return
|
|
223
224
|
if event_type == "session.error":
|
|
224
225
|
return
|
|
225
|
-
if event_type == "session.status":
|
|
226
|
-
status = props.get("status", {})
|
|
227
|
-
if isinstance(status, dict) and status.get("type") == "idle":
|
|
228
|
-
return
|
opencode_runtime/event.py
CHANGED
|
@@ -9,9 +9,10 @@ class OpenCodeEvent:
|
|
|
9
9
|
"""A normalized event from the OpenCode server SSE stream.
|
|
10
10
|
|
|
11
11
|
Attributes:
|
|
12
|
-
type: Event type string, e.g. "message.delta", "message.
|
|
12
|
+
type: Event type string, e.g. "message.part.delta", "message.part.updated",
|
|
13
13
|
"session.error". Mirrors the OpenCode bus event types.
|
|
14
|
-
text: Text content for message.delta events
|
|
14
|
+
text: Text content for message.part.delta events (when properties.field == "text")
|
|
15
|
+
and message.part.updated events (when part.type == "text"); None otherwise.
|
|
15
16
|
raw: The raw event payload from the server. Use this escape hatch
|
|
16
17
|
when you need fields beyond type and text.
|
|
17
18
|
"""
|
|
@@ -19,8 +20,3 @@ class OpenCodeEvent:
|
|
|
19
20
|
type: str
|
|
20
21
|
text: str | None = None
|
|
21
22
|
raw: Any = None
|
|
22
|
-
|
|
23
|
-
def to_sse(self) -> str:
|
|
24
|
-
"""Format as a Server-Sent Events string suitable for HTTP streaming."""
|
|
25
|
-
data = self.text or ""
|
|
26
|
-
return f"event: {self.type}\ndata: {data}\n\n"
|
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/response.py
CHANGED
|
@@ -9,7 +9,7 @@ class OpenCodeResponse:
|
|
|
9
9
|
"""Collected response from a completed ask() call.
|
|
10
10
|
|
|
11
11
|
Attributes:
|
|
12
|
-
text: Full assistant text, concatenated from all message.delta events.
|
|
12
|
+
text: Full assistant text, concatenated from all message.part.delta events.
|
|
13
13
|
raw: List of raw event objects received during the session, in order.
|
|
14
14
|
Use this as an escape hatch when you need parts beyond plain text.
|
|
15
15
|
"""
|
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,
|