runbuoy 0.1.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.
runbuoy/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """RunBuoy CLI and small structured-progress client."""
2
+
3
+ from runbuoy.sdk import attention, message, phase, progress
4
+
5
+ __all__ = ["attention", "message", "phase", "progress"]
6
+ __version__ = "0.1.0"
runbuoy/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from runbuoy.cli.app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1 @@
1
+ """Typer command surface."""
runbuoy/cli/app.py ADDED
@@ -0,0 +1,480 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import socket
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import typer
14
+ from pydantic import ValidationError
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from runbuoy import __version__, sdk
19
+ from runbuoy.config import (
20
+ Config,
21
+ CredentialStore,
22
+ ensure_machine_identity,
23
+ ephemeral_token,
24
+ load_config,
25
+ save_config,
26
+ )
27
+ from runbuoy.executors.tmux import TmuxExecutor
28
+ from runbuoy.ids import uuid7
29
+ from runbuoy.models import ProgressMode, RunManifest
30
+ from runbuoy.networking.client import RemoteClient, flush_pending
31
+ from runbuoy.pairing.flow import pair_machine, public_pairing_fields
32
+ from runbuoy.paths import AppPaths
33
+ from runbuoy.persistence.store import EventQueue
34
+ from runbuoy.security.redaction import safe_message
35
+ from runbuoy.security.titles import safe_title
36
+ from runbuoy.worker.runtime import run_worker
37
+
38
+ app = typer.Typer(
39
+ name="runbuoy",
40
+ help="Keep every run in sight without exposing remote control.",
41
+ no_args_is_help=True,
42
+ )
43
+ emit_app = typer.Typer(help="Emit a structured event to the current local Run.")
44
+ app.add_typer(emit_app, name="emit")
45
+ console = Console(stderr=True)
46
+
47
+
48
+ def _context() -> tuple[AppPaths, Config, CredentialStore, EventQueue]:
49
+ paths = AppPaths.discover()
50
+ paths.ensure()
51
+ config = load_config(paths)
52
+ return paths, config, CredentialStore(paths), EventQueue(paths.database)
53
+
54
+
55
+ def _json_print(value: Any) -> None:
56
+ typer.echo(json.dumps(value, sort_keys=True, default=str))
57
+
58
+
59
+ def _flush_if_paired(config: Config, credentials: CredentialStore, queue: EventQueue) -> int:
60
+ if credentials.get("machine_credential") is None:
61
+ return 0
62
+ client = RemoteClient(config, credentials)
63
+ try:
64
+ return flush_pending(queue, client, batch_size=config.batch_size)
65
+ finally:
66
+ client.close()
67
+
68
+
69
+ def _send_local(path: str, token: str, payload: dict[str, Any]) -> dict[str, Any]:
70
+ request = {"token": token, **payload}
71
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
72
+ client.settimeout(3)
73
+ client.connect(path)
74
+ client.sendall(json.dumps(request).encode() + b"\n")
75
+ response = bytearray()
76
+ while b"\n" not in response:
77
+ chunk = client.recv(4096)
78
+ if not chunk:
79
+ break
80
+ response.extend(chunk)
81
+ return dict(json.loads(bytes(response).split(b"\n", 1)[0]))
82
+
83
+
84
+ @app.command(
85
+ "run",
86
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
87
+ )
88
+ def run_command(
89
+ command: list[str] = typer.Argument(..., help="Command and arguments after --"),
90
+ title: str | None = typer.Option(None, "--title"),
91
+ progress_mode: ProgressMode = typer.Option(ProgressMode.INDETERMINATE, "--progress"),
92
+ pattern: str | None = typer.Option(None, "--pattern"),
93
+ total: float | None = typer.Option(None, "--total"),
94
+ match: str | None = typer.Option(None, "--match"),
95
+ unit: str | None = typer.Option(None, "--unit"),
96
+ share_log_tail: int = typer.Option(0, "--share-log-tail", min=0, max=100),
97
+ json_output: bool = typer.Option(False, "--json"),
98
+ non_interactive: bool = typer.Option(False, "--non-interactive"),
99
+ wait: bool = typer.Option(False, "--wait", help="Wait locally for the result"),
100
+ ) -> None:
101
+ """Start a persistent local tmux worker; target argv never enters remote payloads."""
102
+ del non_interactive
103
+ paths, config, credentials, queue = _context()
104
+ if not command:
105
+ raise typer.BadParameter("a command is required after --")
106
+ if progress_mode == ProgressMode.LINES and (total is None or total <= 0):
107
+ raise typer.BadParameter("--total > 0 is required for lines progress")
108
+ if progress_mode == ProgressMode.REGEX and not pattern:
109
+ raise typer.BadParameter("--pattern is required for regex progress")
110
+ if total is not None and total <= 0:
111
+ raise typer.BadParameter("--total must be greater than zero")
112
+ config = ensure_machine_identity(paths, config)
113
+ machine_id = config.machine_id
114
+ if machine_id is None: # pragma: no cover - guaranteed by ensure_machine_identity
115
+ raise RuntimeError("machine identity initialization failed")
116
+ run_id = str(uuid7())
117
+ run_dir = paths.run_dir(run_id)
118
+ manifest_path = run_dir / "manifest.json"
119
+ log_path = run_dir / "run.log"
120
+ result_path = run_dir / "result.json"
121
+ socket_path = paths.event_socket(run_id)
122
+ session = f"runbuoy-{run_id.replace('-', '')[:16]}"
123
+ manifest = RunManifest(
124
+ run_id=run_id,
125
+ machine_id=machine_id,
126
+ title=safe_title(command, title),
127
+ argv=command,
128
+ cwd=os.getcwd(),
129
+ progress_mode=progress_mode,
130
+ pattern=pattern,
131
+ total=total,
132
+ match=match,
133
+ unit=unit,
134
+ share_log_tail=share_log_tail,
135
+ socket_path=str(socket_path),
136
+ socket_token=ephemeral_token(),
137
+ log_path=str(log_path),
138
+ result_path=str(result_path),
139
+ cancel_grace_seconds=config.cancel_grace_seconds,
140
+ )
141
+ manifest.write_securely(manifest_path)
142
+ queue.create_run(
143
+ run_id=run_id,
144
+ machine_id=machine_id,
145
+ title=manifest.title,
146
+ manifest_path=str(manifest_path),
147
+ log_path=str(log_path),
148
+ result_path=str(result_path),
149
+ socket_path=str(socket_path),
150
+ tmux_session=session,
151
+ )
152
+ try:
153
+ TmuxExecutor().start(session, manifest_path)
154
+ except Exception as error:
155
+ queue.append_event(
156
+ run_id,
157
+ "run.lost",
158
+ {"termination_reason": "worker_start_failed", "message": safe_message(str(error))},
159
+ )
160
+ raise typer.BadParameter(str(error)) from error
161
+ response = {
162
+ "ok": True,
163
+ "run_id": run_id,
164
+ "title": manifest.title,
165
+ "status": "STARTING",
166
+ "local": {
167
+ "status": f"runbuoy status {run_id}",
168
+ "logs": f"runbuoy logs {run_id}",
169
+ "attach": f"runbuoy attach {run_id}",
170
+ "cancel": f"runbuoy cancel {run_id}",
171
+ },
172
+ }
173
+ if json_output:
174
+ _json_print(response)
175
+ else:
176
+ typer.echo(f"Run {run_id} started: {manifest.title}")
177
+ typer.echo(f"Status: runbuoy status {run_id}")
178
+ if wait:
179
+ while not result_path.exists():
180
+ time.sleep(0.1)
181
+ result = json.loads(result_path.read_text(encoding="utf-8"))
182
+ if json_output:
183
+ _json_print({"ok": result["exit_code"] == 0, "result": result})
184
+ raise typer.Exit(code=int(result["exit_code"]))
185
+ _flush_if_paired(config, credentials, queue)
186
+
187
+
188
+ @app.command("list")
189
+ def list_runs(
190
+ json_output: bool = typer.Option(False, "--json"),
191
+ limit: int = typer.Option(20, "--limit", min=1, max=200),
192
+ ) -> None:
193
+ paths, config, credentials, queue = _context()
194
+ del paths
195
+ _flush_if_paired(config, credentials, queue)
196
+ runs = queue.list_runs(limit)
197
+ if json_output:
198
+ _json_print({"runs": runs})
199
+ return
200
+ table = Table("Run ID", "Title", "Status", "Updated")
201
+ for item in runs:
202
+ table.add_row(item["run_id"], item["title"], item["status"], item["updated_at"])
203
+ console.print(table)
204
+
205
+
206
+ @app.command()
207
+ def status(
208
+ run_id: str,
209
+ json_output: bool = typer.Option(False, "--json"),
210
+ ) -> None:
211
+ _paths, config, credentials, queue = _context()
212
+ _flush_if_paired(config, credentials, queue)
213
+ item = queue.get_run(run_id)
214
+ if item is None:
215
+ raise typer.BadParameter(f"unknown run: {run_id}")
216
+ if json_output:
217
+ _json_print({"run": item})
218
+ else:
219
+ for key in ("run_id", "title", "status", "progress", "phase", "safe_message", "exit_code"):
220
+ typer.echo(f"{key}: {item.get(key)}")
221
+
222
+
223
+ @app.command()
224
+ def logs(
225
+ run_id: str,
226
+ follow: bool = typer.Option(False, "--follow", "-f"),
227
+ lines: int = typer.Option(200, "--lines", min=1, max=10_000),
228
+ ) -> None:
229
+ _paths, _config, _credentials, queue = _context()
230
+ item = queue.get_run(run_id)
231
+ if item is None:
232
+ raise typer.BadParameter(f"unknown run: {run_id}")
233
+ path = Path(item["log_path"])
234
+ if not path.exists():
235
+ raise typer.BadParameter("log has not been created yet")
236
+ existing = path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
237
+ typer.echo("\n".join(existing))
238
+ if follow:
239
+ subprocess.call(["tail", "-n", "0", "-f", str(path)])
240
+
241
+
242
+ @app.command()
243
+ def attach(run_id: str) -> None:
244
+ _paths, _config, _credentials, queue = _context()
245
+ item = queue.get_run(run_id)
246
+ if item is None:
247
+ raise typer.BadParameter(f"unknown run: {run_id}")
248
+ session = item.get("tmux_session")
249
+ if not session or not TmuxExecutor().exists(session):
250
+ raise typer.BadParameter("local tmux session is no longer active")
251
+ raise typer.Exit(TmuxExecutor().attach(session))
252
+
253
+
254
+ @app.command()
255
+ def cancel(
256
+ run_id: str,
257
+ json_output: bool = typer.Option(False, "--json"),
258
+ ) -> None:
259
+ _paths, _config, _credentials, queue = _context()
260
+ item = queue.get_run(run_id)
261
+ if item is None:
262
+ raise typer.BadParameter(f"unknown run: {run_id}")
263
+ if item["status"] in {"SUCCEEDED", "FAILED", "CANCELLED", "LOST"}:
264
+ raise typer.BadParameter(f"run is already {item['status']}")
265
+ manifest = RunManifest.model_validate_json(
266
+ Path(item["manifest_path"]).read_text(encoding="utf-8")
267
+ )
268
+ try:
269
+ response = _send_local(manifest.socket_path, manifest.socket_token, {"kind": "cancel"})
270
+ except OSError as error:
271
+ raise typer.BadParameter("local worker is not reachable") from error
272
+ if not response.get("ok"):
273
+ raise typer.BadParameter(f"cancel rejected: {response.get('error')}")
274
+ if json_output:
275
+ _json_print({"ok": True, "run_id": run_id, "requested": "local_cancel"})
276
+ else:
277
+ typer.echo(f"Local cancellation requested for {run_id}")
278
+
279
+
280
+ @app.command()
281
+ def notify(
282
+ title: str = typer.Option(..., "--title"),
283
+ body: str = typer.Option(..., "--body"),
284
+ subtitle: str | None = typer.Option(None, "--subtitle"),
285
+ level: str = typer.Option("info", "--level"),
286
+ field: list[str] | None = typer.Option(None, "--field", help="Repeat label=value"),
287
+ json_output: bool = typer.Option(False, "--json"),
288
+ ) -> None:
289
+ _paths, config, credentials, _queue = _context()
290
+ if level not in {"info", "success", "warning", "error"}:
291
+ raise typer.BadParameter("level must be info, success, warning, or error")
292
+ fields: list[dict[str, str]] = []
293
+ for raw in field or []:
294
+ if "=" not in raw:
295
+ raise typer.BadParameter("--field must be label=value")
296
+ label, value = raw.split("=", 1)
297
+ fields.append(
298
+ {"label": safe_message(label, 80) or "", "value": safe_message(value, 300) or ""}
299
+ )
300
+ payload = {
301
+ "title": safe_title(["notify"], title),
302
+ "subtitle": safe_message(subtitle, 120),
303
+ "body": safe_message(body, 2_000),
304
+ "level": level,
305
+ "fields": fields,
306
+ "source": "cli",
307
+ "machine_id": config.machine_id,
308
+ }
309
+ if credentials.get("machine_credential") is None:
310
+ raise typer.BadParameter("not paired; run `runbuoy pair` first")
311
+ client = RemoteClient(config, credentials)
312
+ try:
313
+ result = client.notify(payload)
314
+ finally:
315
+ client.close()
316
+ if json_output:
317
+ _json_print({"ok": True, "notification": result})
318
+ else:
319
+ typer.echo("Notification accepted")
320
+
321
+
322
+ @app.command()
323
+ def pair(
324
+ no_wait: bool = typer.Option(False, "--no-wait"),
325
+ json_output: bool = typer.Option(False, "--json"),
326
+ ) -> None:
327
+ paths, config, credentials, _queue = _context()
328
+ config = ensure_machine_identity(paths, config)
329
+
330
+ def show_created(created: dict[str, Any], qr_value: str) -> None:
331
+ if json_output:
332
+ _json_print({"ok": True, "state": "pending", "pairing": created, "qr": qr_value})
333
+ return
334
+ import qrcode
335
+
336
+ qr = qrcode.QRCode(border=1)
337
+ qr.add_data(qr_value)
338
+ qr.print_ascii(invert=True)
339
+ code = created.get("short_code") or created.get("challenge")
340
+ typer.echo(f"Code: {code}")
341
+
342
+ try:
343
+ result, _qr_value = pair_machine(
344
+ config,
345
+ credentials,
346
+ wait=not no_wait,
347
+ on_created=show_created,
348
+ )
349
+ except Exception as error:
350
+ raise typer.BadParameter(str(error)) from error
351
+ machine_id = result.get("machine_id")
352
+ if machine_id:
353
+ config = config.model_copy(update={"machine_id": str(machine_id)})
354
+ save_config(paths, config)
355
+ safe_result = public_pairing_fields(result)
356
+ if json_output and not no_wait:
357
+ _json_print({"ok": True, "state": "paired", "pairing": safe_result})
358
+ elif result.get("status") == "paired":
359
+ typer.echo("Machine paired")
360
+
361
+
362
+ @app.command()
363
+ def doctor(json_output: bool = typer.Option(False, "--json")) -> None:
364
+ _paths, config, credentials, queue = _context()
365
+ checks: dict[str, Any] = {
366
+ "cli_installed": True,
367
+ "cli_version": __version__,
368
+ "python_supported": sys.version_info >= (3, 12),
369
+ "platform_supported": platform.system() in {"Darwin", "Linux"},
370
+ "tmux_available": TmuxExecutor.available(),
371
+ "paired": credentials.get("machine_credential") is not None,
372
+ "server_reachable": False,
373
+ "pending_events": len(queue.pending_events(100)),
374
+ }
375
+ try:
376
+ import httpx
377
+
378
+ response = httpx.get(str(config.server_url).rstrip("/") + "/healthz", timeout=2)
379
+ checks["server_reachable"] = response.status_code < 500
380
+ except Exception:
381
+ pass
382
+ required = ("python_supported", "platform_supported", "tmux_available")
383
+ result = {"ok": all(checks[key] for key in required), "checks": checks}
384
+ if json_output:
385
+ _json_print(result)
386
+ else:
387
+ for key, value in checks.items():
388
+ typer.echo(f"{key}: {value}")
389
+
390
+
391
+ @app.command()
392
+ def capabilities(json_output: bool = typer.Option(False, "--json")) -> None:
393
+ progress_modes = [mode.value for mode in ProgressMode]
394
+ result = {
395
+ "schema_version": 1,
396
+ "platforms": ["macos", "linux"],
397
+ "progress_modes": progress_modes,
398
+ "local_commands": ["list", "status", "logs", "attach", "cancel"],
399
+ "remote_control": False,
400
+ "inbound_tcp": False,
401
+ "full_logs_uploaded_by_default": False,
402
+ }
403
+ if json_output:
404
+ _json_print(result)
405
+ else:
406
+ typer.echo("Progress: " + ", ".join(progress_modes))
407
+ typer.echo("Remote control: disabled")
408
+
409
+
410
+ @app.command("config")
411
+ def config_command(
412
+ server_url: str | None = typer.Option(None, "--server-url"),
413
+ machine_name: str | None = typer.Option(None, "--machine-name"),
414
+ json_output: bool = typer.Option(False, "--json"),
415
+ ) -> None:
416
+ paths, config, _credentials, _queue = _context()
417
+ updates: dict[str, Any] = {}
418
+ if server_url:
419
+ updates["server_url"] = server_url
420
+ if machine_name:
421
+ updates["machine_name"] = safe_message(machine_name, 120)
422
+ if updates:
423
+ try:
424
+ config = Config.model_validate({**config.model_dump(), **updates})
425
+ except ValidationError as error:
426
+ raise typer.BadParameter(str(error)) from error
427
+ save_config(paths, config)
428
+ result = {
429
+ "server_url": str(config.server_url),
430
+ "machine_id": config.machine_id,
431
+ "machine_name": config.machine_name,
432
+ "upload_interval_seconds": config.upload_interval_seconds,
433
+ "batch_size": config.batch_size,
434
+ "credential_storage": "keyring-or-mode-0600-fallback",
435
+ }
436
+ if json_output:
437
+ _json_print(result)
438
+ else:
439
+ for key, value in result.items():
440
+ typer.echo(f"{key}: {value}")
441
+
442
+
443
+ @emit_app.command("progress")
444
+ def emit_progress(
445
+ current: float = typer.Option(..., "--current"),
446
+ total: float = typer.Option(..., "--total"),
447
+ unit: str | None = typer.Option(None, "--unit"),
448
+ phase: str | None = typer.Option(None, "--phase"),
449
+ message: str | None = typer.Option(None, "--message"),
450
+ ) -> None:
451
+ sdk.progress(current, total, unit=unit, phase=phase, message=message)
452
+
453
+
454
+ @emit_app.command("phase")
455
+ def emit_phase(value: str = typer.Argument(...)) -> None:
456
+ sdk.phase(value)
457
+
458
+
459
+ @emit_app.command("message")
460
+ def emit_message(value: str = typer.Argument(...)) -> None:
461
+ sdk.message(value)
462
+
463
+
464
+ @emit_app.command("attention")
465
+ def emit_attention(
466
+ value: str = typer.Argument(...),
467
+ status: str = typer.Option("ACTION_REQUIRED", "--status"),
468
+ ) -> None:
469
+ sdk.attention(value, status=status)
470
+
471
+
472
+ @app.command("_worker", hidden=True)
473
+ def worker_command(
474
+ manifest: Path = typer.Option(..., "--manifest", exists=True, dir_okay=False),
475
+ ) -> None:
476
+ raise typer.Exit(run_worker(manifest))
477
+
478
+
479
+ def main() -> None:
480
+ app()
runbuoy/config.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import secrets
7
+ from contextlib import suppress
8
+ from typing import Any, cast
9
+
10
+ from pydantic import BaseModel, Field, HttpUrl
11
+
12
+ from runbuoy.ids import uuid7
13
+ from runbuoy.paths import AppPaths
14
+
15
+
16
+ class Config(BaseModel):
17
+ server_url: HttpUrl = Field(default=HttpUrl("http://127.0.0.1:8000"))
18
+ machine_id: str | None = None
19
+ machine_name: str = Field(default_factory=platform.node)
20
+ upload_interval_seconds: float = Field(default=1.0, ge=0.1, le=60)
21
+ batch_size: int = Field(default=20, ge=1, le=100)
22
+ cancel_grace_seconds: float = Field(default=3.0, ge=0.05, le=60)
23
+
24
+
25
+ def load_config(paths: AppPaths) -> Config:
26
+ paths.ensure()
27
+ if not paths.config_file.exists():
28
+ return Config()
29
+ return Config.model_validate_json(paths.config_file.read_text(encoding="utf-8"))
30
+
31
+
32
+ def save_config(paths: AppPaths, config: Config) -> None:
33
+ paths.ensure()
34
+ paths.config_file.write_text(config.model_dump_json(indent=2), encoding="utf-8")
35
+ paths.config_file.chmod(0o600)
36
+
37
+
38
+ def ensure_machine_identity(paths: AppPaths, config: Config) -> Config:
39
+ """Persist one stable ID before pairing or creating local Runs."""
40
+ if config.machine_id is not None:
41
+ return config
42
+ updated = config.model_copy(update={"machine_id": f"machine_{uuid7().hex}"})
43
+ save_config(paths, updated)
44
+ return updated
45
+
46
+
47
+ class CredentialStore:
48
+ """Keyring-backed credentials with a mode-0600 fallback."""
49
+
50
+ service = "dev.runbuoy.cli"
51
+
52
+ def __init__(self, paths: AppPaths) -> None:
53
+ self.paths = paths
54
+
55
+ def _keyring(self) -> Any | None:
56
+ if os.environ.get("RUNBUOY_DISABLE_KEYRING") == "1":
57
+ return None
58
+ try:
59
+ import keyring
60
+
61
+ backend = keyring.get_keyring()
62
+ if getattr(backend, "priority", 0) <= 0:
63
+ return None
64
+ return keyring
65
+ except Exception:
66
+ return None
67
+
68
+ def get(self, name: str) -> str | None:
69
+ backend = self._keyring()
70
+ if backend is not None:
71
+ try:
72
+ return cast(str | None, backend.get_password(self.service, name))
73
+ except Exception:
74
+ pass
75
+ return self._read_fallback().get(name)
76
+
77
+ def set(self, name: str, value: str) -> None:
78
+ backend = self._keyring()
79
+ if backend is not None:
80
+ try:
81
+ backend.set_password(self.service, name, value)
82
+ return
83
+ except Exception:
84
+ pass
85
+ values = self._read_fallback()
86
+ values[name] = value
87
+ self._write_fallback(values)
88
+
89
+ def delete(self, name: str) -> None:
90
+ backend = self._keyring()
91
+ if backend is not None:
92
+ with suppress(Exception):
93
+ backend.delete_password(self.service, name)
94
+ values = self._read_fallback()
95
+ values.pop(name, None)
96
+ self._write_fallback(values)
97
+
98
+ def _read_fallback(self) -> dict[str, str]:
99
+ path = self.paths.credential_file
100
+ if not path.exists():
101
+ return {}
102
+ if path.stat().st_mode & 0o077:
103
+ raise PermissionError(f"credential file permissions are too broad: {path}")
104
+ raw = json.loads(path.read_text(encoding="utf-8"))
105
+ return {str(key): str(value) for key, value in raw.items()}
106
+
107
+ def _write_fallback(self, values: dict[str, str]) -> None:
108
+ self.paths.ensure()
109
+ path = self.paths.credential_file
110
+ path.write_text(json.dumps(values), encoding="utf-8")
111
+ path.chmod(0o600)
112
+
113
+
114
+ def ephemeral_token() -> str:
115
+ return secrets.token_urlsafe(32)
@@ -0,0 +1,3 @@
1
+ from runbuoy.executors.tmux import TmuxExecutor
2
+
3
+ __all__ = ["TmuxExecutor"]
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ class TmuxUnavailableError(RuntimeError):
11
+ pass
12
+
13
+
14
+ class TmuxExecutor:
15
+ @staticmethod
16
+ def available() -> bool:
17
+ return shutil.which("tmux") is not None
18
+
19
+ def start(self, session: str, manifest_path: Path) -> None:
20
+ if not self.available():
21
+ raise TmuxUnavailableError("tmux is required; install tmux and retry")
22
+ environment = os.environ.copy()
23
+ command = [
24
+ "tmux",
25
+ "new-session",
26
+ "-d",
27
+ "-s",
28
+ session,
29
+ "-e",
30
+ f"RUNBUOY_MANIFEST={manifest_path}",
31
+ "-e",
32
+ f"RUNBUOY_PYTHON={sys.executable}",
33
+ ]
34
+ # A long-lived tmux server has its own stale environment. Explicitly copy
35
+ # only RunBuoy/XDG path selectors needed to find the same local database.
36
+ for name in (
37
+ "RUNBUOY_HOME",
38
+ "RUNBUOY_SOCKET_DIR",
39
+ "RUNBUOY_DISABLE_KEYRING",
40
+ "XDG_CONFIG_HOME",
41
+ "XDG_DATA_HOME",
42
+ "XDG_STATE_HOME",
43
+ "XDG_CACHE_HOME",
44
+ ):
45
+ value = environment.get(name)
46
+ if value is not None:
47
+ command.extend(("-e", f"{name}={value}"))
48
+ command.append('exec "$RUNBUOY_PYTHON" -m runbuoy _worker --manifest "$RUNBUOY_MANIFEST"')
49
+ result = subprocess.run(
50
+ command,
51
+ env=environment,
52
+ capture_output=True,
53
+ text=True,
54
+ check=False,
55
+ )
56
+ if result.returncode:
57
+ raise RuntimeError(f"tmux could not start worker: {result.stderr.strip()}")
58
+
59
+ def attach(self, session: str) -> int:
60
+ return subprocess.call(["tmux", "attach-session", "-t", session])
61
+
62
+ def exists(self, session: str) -> bool:
63
+ result = subprocess.run(
64
+ ["tmux", "has-session", "-t", session],
65
+ capture_output=True,
66
+ check=False,
67
+ )
68
+ return result.returncode == 0