ai-push-hooks 0.2.1 → 0.3.0
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.
- package/CHANGELOG.md +45 -1
- package/README.md +563 -84
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -74
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +406 -75
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Common shell-free child-process execution for runner adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import signal
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from ...paths import path_is_link_or_reparse
|
|
16
|
+
from .contracts import (
|
|
17
|
+
RunnerError,
|
|
18
|
+
RunnerExecutableNotFoundError,
|
|
19
|
+
RunnerSignalError,
|
|
20
|
+
RunnerTimeoutError,
|
|
21
|
+
bounded_diagnostic,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PROCESS_CHUNK_BYTES = 64 * 1024
|
|
26
|
+
# Agent CLIs commonly emit multi-megabyte JSONL event streams. This remains a
|
|
27
|
+
# hard per-stream bound; callers with a tighter budget can override it.
|
|
28
|
+
DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
29
|
+
PROCESS_CLEANUP_GRACE_SECONDS = 1.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, repr=False)
|
|
33
|
+
class ProcessResult:
|
|
34
|
+
"""Bounded child output. Streams are never included in ``repr``."""
|
|
35
|
+
|
|
36
|
+
returncode: int
|
|
37
|
+
stdout: str
|
|
38
|
+
stderr: str
|
|
39
|
+
stdout_truncated: bool = False
|
|
40
|
+
stderr_truncated: bool = False
|
|
41
|
+
# Keep the original bounded bytes for callers that persist process output.
|
|
42
|
+
# The text fields above intentionally retain the runner's surrogateescape
|
|
43
|
+
# decoding contract.
|
|
44
|
+
stdout_bytes: bytes = b""
|
|
45
|
+
stderr_bytes: bytes = b""
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return (
|
|
49
|
+
"ProcessResult("
|
|
50
|
+
f"returncode={self.returncode!r}, stdout=<redacted>, stderr=<redacted>, "
|
|
51
|
+
f"stdout_truncated={self.stdout_truncated!r}, stderr_truncated={self.stderr_truncated!r})"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_bounded(
|
|
56
|
+
stream: object,
|
|
57
|
+
limit: int,
|
|
58
|
+
output: bytearray,
|
|
59
|
+
truncated: list[bool],
|
|
60
|
+
output_lock: threading.Lock,
|
|
61
|
+
) -> None:
|
|
62
|
+
read = getattr(stream, "read")
|
|
63
|
+
try:
|
|
64
|
+
while True:
|
|
65
|
+
try:
|
|
66
|
+
chunk = read(PROCESS_CHUNK_BYTES)
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
return
|
|
69
|
+
if not chunk:
|
|
70
|
+
return
|
|
71
|
+
with output_lock:
|
|
72
|
+
remaining = limit - len(output)
|
|
73
|
+
if remaining > 0:
|
|
74
|
+
output.extend(chunk[:remaining])
|
|
75
|
+
if len(chunk) > remaining:
|
|
76
|
+
truncated[0] = True
|
|
77
|
+
finally:
|
|
78
|
+
_close_pipe(stream)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _signal_process_group(process_group_id: int, signum: int) -> None:
|
|
82
|
+
try:
|
|
83
|
+
os.killpg(process_group_id, signum)
|
|
84
|
+
except (OSError, ProcessLookupError):
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _process_group_exists(process_group_id: int) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
os.killpg(process_group_id, 0)
|
|
91
|
+
except (OSError, ProcessLookupError):
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _wait_process_until(process: subprocess.Popen[bytes], deadline: float) -> None:
|
|
97
|
+
while process.poll() is None:
|
|
98
|
+
remaining = deadline - time.monotonic()
|
|
99
|
+
if remaining <= 0:
|
|
100
|
+
return
|
|
101
|
+
try:
|
|
102
|
+
process.wait(timeout=min(remaining, 0.05))
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _stop_process(process: subprocess.Popen[bytes], deadline: float) -> None:
|
|
108
|
+
"""Terminate this invocation's process group within one shared deadline."""
|
|
109
|
+
|
|
110
|
+
process_group_id = process.pid if os.name == "posix" else None
|
|
111
|
+
if process_group_id is not None:
|
|
112
|
+
# start_new_session=True makes the leader's pid the private process
|
|
113
|
+
# group id. Do not gate this on leader.poll(): descendants can retain
|
|
114
|
+
# the pipes after the leader has already exited.
|
|
115
|
+
_signal_process_group(process_group_id, signal.SIGTERM)
|
|
116
|
+
elif process.poll() is None:
|
|
117
|
+
process.terminate()
|
|
118
|
+
|
|
119
|
+
_wait_process_until(process, deadline)
|
|
120
|
+
|
|
121
|
+
if process_group_id is not None:
|
|
122
|
+
while _process_group_exists(process_group_id):
|
|
123
|
+
remaining = deadline - time.monotonic()
|
|
124
|
+
if remaining <= 0:
|
|
125
|
+
_signal_process_group(process_group_id, signal.SIGKILL)
|
|
126
|
+
try:
|
|
127
|
+
process.wait(timeout=0.1)
|
|
128
|
+
except subprocess.TimeoutExpired:
|
|
129
|
+
pass
|
|
130
|
+
break
|
|
131
|
+
time.sleep(min(0.01, remaining))
|
|
132
|
+
elif process.poll() is None:
|
|
133
|
+
remaining = deadline - time.monotonic()
|
|
134
|
+
if remaining > 0:
|
|
135
|
+
try:
|
|
136
|
+
process.wait(timeout=remaining)
|
|
137
|
+
except subprocess.TimeoutExpired:
|
|
138
|
+
process.kill()
|
|
139
|
+
try:
|
|
140
|
+
process.wait(timeout=0.1)
|
|
141
|
+
except subprocess.TimeoutExpired:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _close_pipe(stream: object) -> None:
|
|
146
|
+
"""Close an unbuffered pipe through its owning stream object."""
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
getattr(stream, "close")()
|
|
150
|
+
except (OSError, ValueError, AttributeError):
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _finish_capture(
|
|
155
|
+
streams: tuple[object, ...],
|
|
156
|
+
threads: tuple[threading.Thread, ...],
|
|
157
|
+
deadline: float,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Bound all joins and force-close pipe fds if a reader remains blocked."""
|
|
160
|
+
|
|
161
|
+
for thread in threads:
|
|
162
|
+
remaining = deadline - time.monotonic()
|
|
163
|
+
if remaining <= 0:
|
|
164
|
+
break
|
|
165
|
+
thread.join(timeout=remaining)
|
|
166
|
+
if any(thread.is_alive() for thread in threads):
|
|
167
|
+
for stream in streams:
|
|
168
|
+
_close_pipe(stream)
|
|
169
|
+
for thread in threads:
|
|
170
|
+
remaining = deadline - time.monotonic()
|
|
171
|
+
if remaining <= 0:
|
|
172
|
+
break
|
|
173
|
+
thread.join(timeout=remaining)
|
|
174
|
+
# Popen creates FileIO objects because bufsize=0. FileIO.close() does not
|
|
175
|
+
# flush buffered data and atomically relinquishes descriptor ownership, so
|
|
176
|
+
# repeated cleanup cannot later close a descriptor reused by another child.
|
|
177
|
+
for stream in streams:
|
|
178
|
+
_close_pipe(stream)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _captured_result(
|
|
182
|
+
returncode: int,
|
|
183
|
+
stdout: bytearray,
|
|
184
|
+
stderr: bytearray,
|
|
185
|
+
stdout_truncated: list[bool],
|
|
186
|
+
stderr_truncated: list[bool],
|
|
187
|
+
stdout_lock: threading.Lock,
|
|
188
|
+
stderr_lock: threading.Lock,
|
|
189
|
+
) -> ProcessResult:
|
|
190
|
+
"""Snapshot bounded streams for an exception without exposing them in it."""
|
|
191
|
+
|
|
192
|
+
with stdout_lock:
|
|
193
|
+
stdout_text = bytes(stdout).decode("utf-8", errors="surrogateescape")
|
|
194
|
+
with stderr_lock:
|
|
195
|
+
stderr_text = bytes(stderr).decode("utf-8", errors="surrogateescape")
|
|
196
|
+
return ProcessResult(
|
|
197
|
+
returncode=returncode,
|
|
198
|
+
stdout=stdout_text,
|
|
199
|
+
stderr=stderr_text,
|
|
200
|
+
stdout_truncated=stdout_truncated[0],
|
|
201
|
+
stderr_truncated=stderr_truncated[0],
|
|
202
|
+
stdout_bytes=bytes(stdout),
|
|
203
|
+
stderr_bytes=bytes(stderr),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run_process(
|
|
208
|
+
argv: Sequence[str],
|
|
209
|
+
*,
|
|
210
|
+
cwd: pathlib.Path,
|
|
211
|
+
input_text: str | None = None,
|
|
212
|
+
input_path: pathlib.Path | None = None,
|
|
213
|
+
timeout_seconds: float,
|
|
214
|
+
env: Mapping[str, str] | None = None,
|
|
215
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
216
|
+
) -> ProcessResult:
|
|
217
|
+
"""Run an argv vector directly, with bounded concurrent stream capture.
|
|
218
|
+
|
|
219
|
+
``shell=False`` is explicit and prompt text is written only to stdin. The
|
|
220
|
+
environment is accepted for the child but is never inspected or included
|
|
221
|
+
in errors. Non-zero exits are returned for adapter-specific normalization;
|
|
222
|
+
timeout and signal termination are raised as distinct fail-closed errors.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
if not isinstance(argv, (tuple, list)) or not argv or any(
|
|
226
|
+
not isinstance(argument, str) or not argument or "\x00" in argument for argument in argv
|
|
227
|
+
):
|
|
228
|
+
raise RunnerError("runner command must be a non-empty NUL-free argv vector")
|
|
229
|
+
if not isinstance(cwd, pathlib.Path):
|
|
230
|
+
cwd = pathlib.Path(cwd)
|
|
231
|
+
if not cwd.is_dir():
|
|
232
|
+
raise RunnerError("runner cwd must be an existing directory")
|
|
233
|
+
if timeout_seconds <= 0:
|
|
234
|
+
raise RunnerError("runner timeout must be greater than zero")
|
|
235
|
+
if max_output_bytes < 0:
|
|
236
|
+
raise RunnerError("runner output bound must not be negative")
|
|
237
|
+
if input_text is not None and input_path is not None:
|
|
238
|
+
raise RunnerError("runner input_text and input_path are mutually exclusive")
|
|
239
|
+
input_file = None
|
|
240
|
+
if input_path is not None:
|
|
241
|
+
if not isinstance(input_path, pathlib.Path):
|
|
242
|
+
input_path = pathlib.Path(input_path)
|
|
243
|
+
if path_is_link_or_reparse(input_path):
|
|
244
|
+
raise RunnerError("runner input file must not be a symlink or reparse point")
|
|
245
|
+
descriptor = -1
|
|
246
|
+
try:
|
|
247
|
+
# O_NONBLOCK prevents opening a FIFO from waiting for a writer;
|
|
248
|
+
# O_NOFOLLOW closes the symlink race between validation and open.
|
|
249
|
+
flags = (
|
|
250
|
+
os.O_RDONLY
|
|
251
|
+
| os.O_NONBLOCK
|
|
252
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
253
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
254
|
+
)
|
|
255
|
+
descriptor = os.open(input_path, flags)
|
|
256
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
257
|
+
raise RunnerError("runner input file must be an ordinary regular file")
|
|
258
|
+
input_file = os.fdopen(descriptor, "rb", buffering=0)
|
|
259
|
+
descriptor = -1
|
|
260
|
+
except RunnerError:
|
|
261
|
+
raise
|
|
262
|
+
except (OSError, ValueError) as exc:
|
|
263
|
+
raise RunnerError("runner input file could not be opened safely") from exc
|
|
264
|
+
finally:
|
|
265
|
+
if descriptor >= 0:
|
|
266
|
+
os.close(descriptor)
|
|
267
|
+
|
|
268
|
+
popen_kwargs: dict[str, object] = {
|
|
269
|
+
"cwd": cwd,
|
|
270
|
+
"stdin": subprocess.PIPE,
|
|
271
|
+
"stdout": subprocess.PIPE,
|
|
272
|
+
"stderr": subprocess.PIPE,
|
|
273
|
+
"shell": False,
|
|
274
|
+
"env": dict(env) if env is not None else None,
|
|
275
|
+
# Buffered pipe wrappers cannot safely be bypassed with os.close(): the
|
|
276
|
+
# wrapper still owns the descriptor and may close a later reused fd in
|
|
277
|
+
# its destructor. Unbuffered FileIO makes close bounded and ownership
|
|
278
|
+
# explicit while the reader threads retain concurrent stream draining.
|
|
279
|
+
"bufsize": 0,
|
|
280
|
+
}
|
|
281
|
+
if os.name == "posix":
|
|
282
|
+
popen_kwargs["start_new_session"] = True
|
|
283
|
+
try:
|
|
284
|
+
process = subprocess.Popen(list(argv), **popen_kwargs)
|
|
285
|
+
except FileNotFoundError as exc:
|
|
286
|
+
if input_file is not None:
|
|
287
|
+
input_file.close()
|
|
288
|
+
raise RunnerExecutableNotFoundError(
|
|
289
|
+
"runner executable was not found",
|
|
290
|
+
details=bounded_diagnostic(argv[0]),
|
|
291
|
+
) from exc
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
if input_file is not None:
|
|
294
|
+
input_file.close()
|
|
295
|
+
raise RunnerError("runner process could not be started", details=type(exc).__name__) from exc
|
|
296
|
+
|
|
297
|
+
if process.stdout is None or process.stderr is None or process.stdin is None:
|
|
298
|
+
_stop_process(process, time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS)
|
|
299
|
+
if input_file is not None:
|
|
300
|
+
input_file.close()
|
|
301
|
+
raise RunnerError("runner process pipes were not available")
|
|
302
|
+
|
|
303
|
+
stdout = bytearray()
|
|
304
|
+
stderr = bytearray()
|
|
305
|
+
stdout_lock = threading.Lock()
|
|
306
|
+
stderr_lock = threading.Lock()
|
|
307
|
+
stdout_truncated = [False]
|
|
308
|
+
stderr_truncated = [False]
|
|
309
|
+
stdout_thread = threading.Thread(
|
|
310
|
+
target=_read_bounded,
|
|
311
|
+
args=(process.stdout, max_output_bytes, stdout, stdout_truncated, stdout_lock),
|
|
312
|
+
daemon=True,
|
|
313
|
+
)
|
|
314
|
+
stderr_thread = threading.Thread(
|
|
315
|
+
target=_read_bounded,
|
|
316
|
+
args=(process.stderr, max_output_bytes, stderr, stderr_truncated, stderr_lock),
|
|
317
|
+
daemon=True,
|
|
318
|
+
)
|
|
319
|
+
stdout_thread.start()
|
|
320
|
+
stderr_thread.start()
|
|
321
|
+
|
|
322
|
+
input_bytes = None if input_text is None else input_text.encode("utf-8", errors="surrogateescape")
|
|
323
|
+
|
|
324
|
+
def write_input() -> None:
|
|
325
|
+
try:
|
|
326
|
+
if input_file is not None:
|
|
327
|
+
while True:
|
|
328
|
+
chunk = input_file.read(PROCESS_CHUNK_BYTES)
|
|
329
|
+
if not chunk:
|
|
330
|
+
break
|
|
331
|
+
offset = 0
|
|
332
|
+
while offset < len(chunk):
|
|
333
|
+
written = process.stdin.write(chunk[offset:])
|
|
334
|
+
if not written:
|
|
335
|
+
return
|
|
336
|
+
offset += written
|
|
337
|
+
elif input_bytes:
|
|
338
|
+
offset = 0
|
|
339
|
+
while offset < len(input_bytes):
|
|
340
|
+
written = process.stdin.write(input_bytes[offset:])
|
|
341
|
+
if not written:
|
|
342
|
+
break
|
|
343
|
+
offset += written
|
|
344
|
+
except (BrokenPipeError, OSError, ValueError):
|
|
345
|
+
pass
|
|
346
|
+
finally:
|
|
347
|
+
if input_file is not None:
|
|
348
|
+
_close_pipe(input_file)
|
|
349
|
+
_close_pipe(process.stdin)
|
|
350
|
+
|
|
351
|
+
input_thread = threading.Thread(target=write_input, daemon=True)
|
|
352
|
+
input_thread.start()
|
|
353
|
+
returncode: int | None = None
|
|
354
|
+
timeout_cause: subprocess.TimeoutExpired | None = None
|
|
355
|
+
try:
|
|
356
|
+
returncode = process.wait(timeout=timeout_seconds)
|
|
357
|
+
except subprocess.TimeoutExpired as exc:
|
|
358
|
+
timeout_cause = exc
|
|
359
|
+
finally:
|
|
360
|
+
cleanup_deadline = time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS
|
|
361
|
+
_stop_process(process, cleanup_deadline)
|
|
362
|
+
_finish_capture(
|
|
363
|
+
(process.stdin, process.stdout, process.stderr),
|
|
364
|
+
(input_thread, stdout_thread, stderr_thread),
|
|
365
|
+
cleanup_deadline,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if returncode is None:
|
|
369
|
+
returncode = process.poll()
|
|
370
|
+
if timeout_cause is not None:
|
|
371
|
+
timeout_returncode = returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
|
|
372
|
+
process_result = _captured_result(
|
|
373
|
+
timeout_returncode,
|
|
374
|
+
stdout,
|
|
375
|
+
stderr,
|
|
376
|
+
stdout_truncated,
|
|
377
|
+
stderr_truncated,
|
|
378
|
+
stdout_lock,
|
|
379
|
+
stderr_lock,
|
|
380
|
+
)
|
|
381
|
+
error = RunnerTimeoutError("runner process exceeded its timeout")
|
|
382
|
+
setattr(error, "_process_result", process_result)
|
|
383
|
+
raise error from timeout_cause
|
|
384
|
+
|
|
385
|
+
if returncode is None: # pragma: no cover - process.wait either returns or raises
|
|
386
|
+
raise RunnerError("runner process returned no exit status")
|
|
387
|
+
process_result = _captured_result(
|
|
388
|
+
returncode,
|
|
389
|
+
stdout,
|
|
390
|
+
stderr,
|
|
391
|
+
stdout_truncated,
|
|
392
|
+
stderr_truncated,
|
|
393
|
+
stdout_lock,
|
|
394
|
+
stderr_lock,
|
|
395
|
+
)
|
|
396
|
+
if returncode < 0:
|
|
397
|
+
error = RunnerSignalError(
|
|
398
|
+
"runner process terminated by signal",
|
|
399
|
+
details=str(-returncode),
|
|
400
|
+
)
|
|
401
|
+
setattr(error, "_process_result", process_result)
|
|
402
|
+
raise error
|
|
403
|
+
return process_result
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Static, lazy registry for the four built-in runner adapter types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Callable, Mapping
|
|
9
|
+
|
|
10
|
+
from .contracts import (
|
|
11
|
+
Runner,
|
|
12
|
+
RunnerAdapterUnavailableError,
|
|
13
|
+
RunnerContractError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
KNOWN_RUNNER_TYPES = ("opencode", "codex", "claude", "command")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class LazyRunnerSpec:
|
|
22
|
+
"""Import and construct an adapter only when that type is selected."""
|
|
23
|
+
|
|
24
|
+
module: str
|
|
25
|
+
factory_name: str = "create_runner"
|
|
26
|
+
|
|
27
|
+
def load(self) -> Runner:
|
|
28
|
+
try:
|
|
29
|
+
module: ModuleType = importlib.import_module(self.module)
|
|
30
|
+
except ModuleNotFoundError as exc:
|
|
31
|
+
if exc.name == self.module:
|
|
32
|
+
raise RunnerAdapterUnavailableError(
|
|
33
|
+
"selected runner adapter is not installed in this build"
|
|
34
|
+
) from exc
|
|
35
|
+
raise RunnerAdapterUnavailableError(
|
|
36
|
+
"selected runner adapter could not load its dependency",
|
|
37
|
+
details=type(exc).__name__,
|
|
38
|
+
) from exc
|
|
39
|
+
try:
|
|
40
|
+
factory: Callable[[], Runner] = getattr(module, self.factory_name)
|
|
41
|
+
except AttributeError as exc:
|
|
42
|
+
raise RunnerAdapterUnavailableError(
|
|
43
|
+
"selected runner adapter has no factory"
|
|
44
|
+
) from exc
|
|
45
|
+
try:
|
|
46
|
+
runner = factory()
|
|
47
|
+
except RunnerAdapterUnavailableError:
|
|
48
|
+
raise
|
|
49
|
+
except Exception as exc: # noqa: BLE001
|
|
50
|
+
raise RunnerAdapterUnavailableError(
|
|
51
|
+
"selected runner adapter could not be constructed",
|
|
52
|
+
details=type(exc).__name__,
|
|
53
|
+
) from exc
|
|
54
|
+
if not callable(getattr(runner, "run", None)):
|
|
55
|
+
raise RunnerAdapterUnavailableError("selected runner does not implement run(request)")
|
|
56
|
+
return runner
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_DEFAULT_SPECS = {
|
|
60
|
+
"opencode": LazyRunnerSpec("ai_push_hooks.executors.runners.opencode"),
|
|
61
|
+
"codex": LazyRunnerSpec("ai_push_hooks.executors.runners.codex"),
|
|
62
|
+
"claude": LazyRunnerSpec("ai_push_hooks.executors.runners.claude"),
|
|
63
|
+
"command": LazyRunnerSpec("ai_push_hooks.executors.runners.command"),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RunnerRegistry:
|
|
68
|
+
"""A fixed registry; it intentionally has no runtime plugin registration API."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None) -> None:
|
|
71
|
+
selected = _DEFAULT_SPECS if specs is None else dict(specs)
|
|
72
|
+
unknown = set(selected) - set(KNOWN_RUNNER_TYPES)
|
|
73
|
+
if unknown:
|
|
74
|
+
raise RunnerContractError(
|
|
75
|
+
"runner registry contains unsupported adapter types: " + ", ".join(sorted(unknown))
|
|
76
|
+
)
|
|
77
|
+
if set(selected) != set(KNOWN_RUNNER_TYPES):
|
|
78
|
+
raise RunnerContractError("runner registry must contain all four known adapter types")
|
|
79
|
+
self._specs = dict(selected)
|
|
80
|
+
self._loaded: dict[str, Runner] = {}
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def known_types(self) -> tuple[str, ...]:
|
|
84
|
+
return KNOWN_RUNNER_TYPES
|
|
85
|
+
|
|
86
|
+
def get(self, runner_type: str) -> Runner:
|
|
87
|
+
if runner_type not in self._specs:
|
|
88
|
+
raise RunnerContractError(f"unknown runner type: {runner_type!r}")
|
|
89
|
+
if runner_type not in self._loaded:
|
|
90
|
+
spec = self._specs[runner_type]
|
|
91
|
+
self._loaded[runner_type] = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
|
|
92
|
+
return self._loaded[runner_type]
|
|
93
|
+
|
|
94
|
+
def __contains__(self, runner_type: object) -> bool:
|
|
95
|
+
return runner_type in self._specs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
DEFAULT_RUNNER_REGISTRY = RunnerRegistry()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_runner(runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY) -> Runner:
|
|
102
|
+
"""Resolve one of the four static adapter types lazily."""
|
|
103
|
+
|
|
104
|
+
return registry.get(runner_type)
|