ai-push-hooks 0.3.0 → 0.3.2
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 +78 -2
- package/README.md +80 -993
- package/SECURITY.md +21 -16
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +6 -6
- package/docs/configuration.md +263 -0
- package/package.json +4 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -19
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +239 -54
- package/src/ai_push_hooks/engine.py +94 -43
- package/src/ai_push_hooks/executors/apply.py +124 -46
- package/src/ai_push_hooks/executors/ask.py +30 -536
- package/src/ai_push_hooks/executors/exec.py +73 -818
- package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
- package/src/ai_push_hooks/executors/runners/claude.py +18 -6
- package/src/ai_push_hooks/executors/runners/codex.py +9 -3
- package/src/ai_push_hooks/executors/runners/command.py +25 -9
- package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
- package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
- package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
- package/src/ai_push_hooks/executors/runners/process.py +97 -17
- package/src/ai_push_hooks/executors/runners/registry.py +31 -9
- package/src/ai_push_hooks/executors/step_commands.py +65 -20
- package/src/ai_push_hooks/git_utils.py +899 -0
- package/src/ai_push_hooks/hook.py +49 -13
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +19 -8
- package/src/ai_push_hooks/modules/docs.py +146 -96
- package/src/ai_push_hooks/modules/pr.py +19 -8
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +182 -105
- package/src/ai_push_hooks/plugins.py +3 -1
- package/src/ai_push_hooks/prompts_builtin.py +1 -1
- package/src/ai_push_hooks/types.py +48 -27
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
- package/run.sh +0 -29
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import math
|
|
5
6
|
import os
|
|
6
7
|
import pathlib
|
|
7
8
|
import signal
|
|
@@ -58,6 +59,7 @@ def _read_bounded(
|
|
|
58
59
|
output: bytearray,
|
|
59
60
|
truncated: list[bool],
|
|
60
61
|
output_lock: threading.Lock,
|
|
62
|
+
output_limit_reached: threading.Event,
|
|
61
63
|
) -> None:
|
|
62
64
|
read = getattr(stream, "read")
|
|
63
65
|
try:
|
|
@@ -74,6 +76,7 @@ def _read_bounded(
|
|
|
74
76
|
output.extend(chunk[:remaining])
|
|
75
77
|
if len(chunk) > remaining:
|
|
76
78
|
truncated[0] = True
|
|
79
|
+
output_limit_reached.set()
|
|
77
80
|
finally:
|
|
78
81
|
_close_pipe(stream)
|
|
79
82
|
|
|
@@ -213,6 +216,7 @@ def run_process(
|
|
|
213
216
|
timeout_seconds: float,
|
|
214
217
|
env: Mapping[str, str] | None = None,
|
|
215
218
|
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
219
|
+
max_stderr_bytes: int | None = None,
|
|
216
220
|
) -> ProcessResult:
|
|
217
221
|
"""Run an argv vector directly, with bounded concurrent stream capture.
|
|
218
222
|
|
|
@@ -222,18 +226,39 @@ def run_process(
|
|
|
222
226
|
timeout and signal termination are raised as distinct fail-closed errors.
|
|
223
227
|
"""
|
|
224
228
|
|
|
225
|
-
if
|
|
226
|
-
not isinstance(
|
|
229
|
+
if (
|
|
230
|
+
not isinstance(argv, (tuple, list))
|
|
231
|
+
or not argv
|
|
232
|
+
or any(
|
|
233
|
+
not isinstance(argument, str) or not argument or "\x00" in argument
|
|
234
|
+
for argument in argv
|
|
235
|
+
)
|
|
227
236
|
):
|
|
228
237
|
raise RunnerError("runner command must be a non-empty NUL-free argv vector")
|
|
229
238
|
if not isinstance(cwd, pathlib.Path):
|
|
230
239
|
cwd = pathlib.Path(cwd)
|
|
231
240
|
if not cwd.is_dir():
|
|
232
241
|
raise RunnerError("runner cwd must be an existing directory")
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
242
|
+
if (
|
|
243
|
+
isinstance(timeout_seconds, bool)
|
|
244
|
+
or not isinstance(timeout_seconds, (int, float))
|
|
245
|
+
or not math.isfinite(timeout_seconds)
|
|
246
|
+
or timeout_seconds <= 0
|
|
247
|
+
):
|
|
248
|
+
raise RunnerError("runner timeout must be a finite number greater than zero")
|
|
249
|
+
if (
|
|
250
|
+
isinstance(max_output_bytes, bool)
|
|
251
|
+
or not isinstance(max_output_bytes, int)
|
|
252
|
+
or max_output_bytes < 0
|
|
253
|
+
):
|
|
236
254
|
raise RunnerError("runner output bound must not be negative")
|
|
255
|
+
stderr_limit = max_output_bytes if max_stderr_bytes is None else max_stderr_bytes
|
|
256
|
+
if (
|
|
257
|
+
isinstance(stderr_limit, bool)
|
|
258
|
+
or not isinstance(stderr_limit, int)
|
|
259
|
+
or stderr_limit < 0
|
|
260
|
+
):
|
|
261
|
+
raise RunnerError("runner stderr output bound must not be negative")
|
|
237
262
|
if input_text is not None and input_path is not None:
|
|
238
263
|
raise RunnerError("runner input_text and input_path are mutually exclusive")
|
|
239
264
|
input_file = None
|
|
@@ -241,7 +266,9 @@ def run_process(
|
|
|
241
266
|
if not isinstance(input_path, pathlib.Path):
|
|
242
267
|
input_path = pathlib.Path(input_path)
|
|
243
268
|
if path_is_link_or_reparse(input_path):
|
|
244
|
-
raise RunnerError(
|
|
269
|
+
raise RunnerError(
|
|
270
|
+
"runner input file must not be a symlink or reparse point"
|
|
271
|
+
)
|
|
245
272
|
descriptor = -1
|
|
246
273
|
try:
|
|
247
274
|
# O_NONBLOCK prevents opening a FIFO from waiting for a writer;
|
|
@@ -292,7 +319,9 @@ def run_process(
|
|
|
292
319
|
except OSError as exc:
|
|
293
320
|
if input_file is not None:
|
|
294
321
|
input_file.close()
|
|
295
|
-
raise RunnerError(
|
|
322
|
+
raise RunnerError(
|
|
323
|
+
"runner process could not be started", details=type(exc).__name__
|
|
324
|
+
) from exc
|
|
296
325
|
|
|
297
326
|
if process.stdout is None or process.stderr is None or process.stdin is None:
|
|
298
327
|
_stop_process(process, time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS)
|
|
@@ -306,20 +335,39 @@ def run_process(
|
|
|
306
335
|
stderr_lock = threading.Lock()
|
|
307
336
|
stdout_truncated = [False]
|
|
308
337
|
stderr_truncated = [False]
|
|
338
|
+
output_limit_reached = threading.Event()
|
|
309
339
|
stdout_thread = threading.Thread(
|
|
310
340
|
target=_read_bounded,
|
|
311
|
-
args=(
|
|
341
|
+
args=(
|
|
342
|
+
process.stdout,
|
|
343
|
+
max_output_bytes,
|
|
344
|
+
stdout,
|
|
345
|
+
stdout_truncated,
|
|
346
|
+
stdout_lock,
|
|
347
|
+
output_limit_reached,
|
|
348
|
+
),
|
|
312
349
|
daemon=True,
|
|
313
350
|
)
|
|
314
351
|
stderr_thread = threading.Thread(
|
|
315
352
|
target=_read_bounded,
|
|
316
|
-
args=(
|
|
353
|
+
args=(
|
|
354
|
+
process.stderr,
|
|
355
|
+
stderr_limit,
|
|
356
|
+
stderr,
|
|
357
|
+
stderr_truncated,
|
|
358
|
+
stderr_lock,
|
|
359
|
+
output_limit_reached,
|
|
360
|
+
),
|
|
317
361
|
daemon=True,
|
|
318
362
|
)
|
|
319
363
|
stdout_thread.start()
|
|
320
364
|
stderr_thread.start()
|
|
321
365
|
|
|
322
|
-
input_bytes =
|
|
366
|
+
input_bytes = (
|
|
367
|
+
None
|
|
368
|
+
if input_text is None
|
|
369
|
+
else input_text.encode("utf-8", errors="surrogateescape")
|
|
370
|
+
)
|
|
323
371
|
|
|
324
372
|
def write_input() -> None:
|
|
325
373
|
try:
|
|
@@ -350,25 +398,57 @@ def run_process(
|
|
|
350
398
|
|
|
351
399
|
input_thread = threading.Thread(target=write_input, daemon=True)
|
|
352
400
|
input_thread.start()
|
|
401
|
+
process_exited = threading.Event()
|
|
402
|
+
process_returncode: list[int | None] = [None]
|
|
403
|
+
|
|
404
|
+
def wait_for_process_exit() -> None:
|
|
405
|
+
process_returncode[0] = process.wait()
|
|
406
|
+
process_exited.set()
|
|
407
|
+
|
|
408
|
+
process_wait_thread = threading.Thread(target=wait_for_process_exit, daemon=True)
|
|
409
|
+
process_wait_thread.start()
|
|
353
410
|
returncode: int | None = None
|
|
354
411
|
timeout_cause: subprocess.TimeoutExpired | None = None
|
|
412
|
+
output_limit_cause = False
|
|
355
413
|
try:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
414
|
+
deadline = time.monotonic() + timeout_seconds
|
|
415
|
+
while True:
|
|
416
|
+
remaining = deadline - time.monotonic()
|
|
417
|
+
if remaining <= 0:
|
|
418
|
+
timeout_cause = subprocess.TimeoutExpired(list(argv), timeout_seconds)
|
|
419
|
+
break
|
|
420
|
+
if output_limit_reached.is_set():
|
|
421
|
+
# A short grace period preserves the natural exit status for
|
|
422
|
+
# commands that wrote their final bounded chunk and were
|
|
423
|
+
# already exiting. A still-live child is stopped below
|
|
424
|
+
# rather than allowed to drain discarded output until timeout.
|
|
425
|
+
if process_exited.wait(timeout=min(remaining, 0.05)):
|
|
426
|
+
returncode = process_returncode[0]
|
|
427
|
+
else:
|
|
428
|
+
output_limit_cause = True
|
|
429
|
+
break
|
|
430
|
+
if process_exited.wait(timeout=min(remaining, 0.05)):
|
|
431
|
+
returncode = process_returncode[0]
|
|
432
|
+
break
|
|
359
433
|
finally:
|
|
360
434
|
cleanup_deadline = time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS
|
|
361
435
|
_stop_process(process, cleanup_deadline)
|
|
362
436
|
_finish_capture(
|
|
363
437
|
(process.stdin, process.stdout, process.stderr),
|
|
364
|
-
(input_thread, stdout_thread, stderr_thread),
|
|
438
|
+
(input_thread, stdout_thread, stderr_thread, process_wait_thread),
|
|
365
439
|
cleanup_deadline,
|
|
366
440
|
)
|
|
367
441
|
|
|
368
442
|
if returncode is None:
|
|
369
|
-
returncode =
|
|
443
|
+
returncode = (
|
|
444
|
+
process_returncode[0]
|
|
445
|
+
if process_returncode[0] is not None
|
|
446
|
+
else process.poll()
|
|
447
|
+
)
|
|
370
448
|
if timeout_cause is not None:
|
|
371
|
-
timeout_returncode =
|
|
449
|
+
timeout_returncode = (
|
|
450
|
+
returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
|
|
451
|
+
)
|
|
372
452
|
process_result = _captured_result(
|
|
373
453
|
timeout_returncode,
|
|
374
454
|
stdout,
|
|
@@ -393,7 +473,7 @@ def run_process(
|
|
|
393
473
|
stdout_lock,
|
|
394
474
|
stderr_lock,
|
|
395
475
|
)
|
|
396
|
-
if returncode < 0:
|
|
476
|
+
if returncode < 0 and not output_limit_cause:
|
|
397
477
|
error = RunnerSignalError(
|
|
398
478
|
"runner process terminated by signal",
|
|
399
479
|
details=str(-returncode),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import importlib
|
|
6
|
+
import threading
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from types import ModuleType
|
|
8
9
|
from typing import Callable, Mapping
|
|
@@ -52,7 +53,9 @@ class LazyRunnerSpec:
|
|
|
52
53
|
details=type(exc).__name__,
|
|
53
54
|
) from exc
|
|
54
55
|
if not callable(getattr(runner, "run", None)):
|
|
55
|
-
raise RunnerAdapterUnavailableError(
|
|
56
|
+
raise RunnerAdapterUnavailableError(
|
|
57
|
+
"selected runner does not implement run(request)"
|
|
58
|
+
)
|
|
56
59
|
return runner
|
|
57
60
|
|
|
58
61
|
|
|
@@ -67,17 +70,25 @@ _DEFAULT_SPECS = {
|
|
|
67
70
|
class RunnerRegistry:
|
|
68
71
|
"""A fixed registry; it intentionally has no runtime plugin registration API."""
|
|
69
72
|
|
|
70
|
-
def __init__(
|
|
73
|
+
def __init__(
|
|
74
|
+
self, specs: Mapping[str, LazyRunnerSpec | Callable[[], Runner]] | None = None
|
|
75
|
+
) -> None:
|
|
71
76
|
selected = _DEFAULT_SPECS if specs is None else dict(specs)
|
|
72
77
|
unknown = set(selected) - set(KNOWN_RUNNER_TYPES)
|
|
73
78
|
if unknown:
|
|
74
79
|
raise RunnerContractError(
|
|
75
|
-
"runner registry contains unsupported adapter types: "
|
|
80
|
+
"runner registry contains unsupported adapter types: "
|
|
81
|
+
+ ", ".join(sorted(unknown))
|
|
76
82
|
)
|
|
77
83
|
if set(selected) != set(KNOWN_RUNNER_TYPES):
|
|
78
|
-
raise RunnerContractError(
|
|
84
|
+
raise RunnerContractError(
|
|
85
|
+
"runner registry must contain all four known adapter types"
|
|
86
|
+
)
|
|
79
87
|
self._specs = dict(selected)
|
|
80
88
|
self._loaded: dict[str, Runner] = {}
|
|
89
|
+
self._load_locks = {
|
|
90
|
+
runner_type: threading.Lock() for runner_type in KNOWN_RUNNER_TYPES
|
|
91
|
+
}
|
|
81
92
|
|
|
82
93
|
@property
|
|
83
94
|
def known_types(self) -> tuple[str, ...]:
|
|
@@ -86,10 +97,19 @@ class RunnerRegistry:
|
|
|
86
97
|
def get(self, runner_type: str) -> Runner:
|
|
87
98
|
if runner_type not in self._specs:
|
|
88
99
|
raise RunnerContractError(f"unknown runner type: {runner_type!r}")
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
runner = self._loaded.get(runner_type)
|
|
101
|
+
if runner is not None:
|
|
102
|
+
return runner
|
|
103
|
+
# Initialization is serialized only for the selected adapter. Other
|
|
104
|
+
# runner types may load concurrently, which matters for capability
|
|
105
|
+
# probes such as Claude's --help check.
|
|
106
|
+
with self._load_locks[runner_type]:
|
|
107
|
+
runner = self._loaded.get(runner_type)
|
|
108
|
+
if runner is None:
|
|
109
|
+
spec = self._specs[runner_type]
|
|
110
|
+
runner = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
|
|
111
|
+
self._loaded[runner_type] = runner
|
|
112
|
+
return runner
|
|
93
113
|
|
|
94
114
|
def __contains__(self, runner_type: object) -> bool:
|
|
95
115
|
return runner_type in self._specs
|
|
@@ -98,7 +118,9 @@ class RunnerRegistry:
|
|
|
98
118
|
DEFAULT_RUNNER_REGISTRY = RunnerRegistry()
|
|
99
119
|
|
|
100
120
|
|
|
101
|
-
def get_runner(
|
|
121
|
+
def get_runner(
|
|
122
|
+
runner_type: str, *, registry: RunnerRegistry = DEFAULT_RUNNER_REGISTRY
|
|
123
|
+
) -> Runner:
|
|
102
124
|
"""Resolve one of the four static adapter types lazily."""
|
|
103
125
|
|
|
104
126
|
return registry.get(runner_type)
|
|
@@ -19,7 +19,13 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from ..artifacts import ArtifactStore
|
|
21
21
|
from ..paths import path_is_link_or_reparse
|
|
22
|
-
from ..types import
|
|
22
|
+
from ..types import (
|
|
23
|
+
DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS,
|
|
24
|
+
HookError,
|
|
25
|
+
ModuleRuntimeState,
|
|
26
|
+
RuntimeContext,
|
|
27
|
+
StepConfig,
|
|
28
|
+
)
|
|
23
29
|
from .runners.contracts import bounded_diagnostic, redact_diagnostic
|
|
24
30
|
from .runners.process import (
|
|
25
31
|
DEFAULT_MAX_OUTPUT_BYTES,
|
|
@@ -27,7 +33,6 @@ from .runners.process import (
|
|
|
27
33
|
run_process,
|
|
28
34
|
)
|
|
29
35
|
|
|
30
|
-
DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
|
|
31
36
|
STEP_COMMAND_STDOUT_ARTIFACT = "stdout.txt"
|
|
32
37
|
STEP_COMMAND_STDERR_ARTIFACT = "stderr.txt"
|
|
33
38
|
STEP_COMMAND_RESULT_ARTIFACT = "result.json"
|
|
@@ -74,7 +79,10 @@ class StepCommandResult:
|
|
|
74
79
|
raise TypeError("step command returncode must be an integer")
|
|
75
80
|
if not isinstance(self.stdout, bytes) or not isinstance(self.stderr, bytes):
|
|
76
81
|
raise TypeError("step command streams must be bytes")
|
|
77
|
-
if
|
|
82
|
+
if (
|
|
83
|
+
type(self.stdout_truncated) is not bool
|
|
84
|
+
or type(self.stderr_truncated) is not bool
|
|
85
|
+
):
|
|
78
86
|
raise TypeError("step command truncation flags must be booleans")
|
|
79
87
|
|
|
80
88
|
def __repr__(self) -> str:
|
|
@@ -113,18 +121,24 @@ def _validated_inputs(
|
|
|
113
121
|
if inputs is None:
|
|
114
122
|
return {}
|
|
115
123
|
if not isinstance(inputs, Mapping):
|
|
116
|
-
raise StepCommandError(
|
|
124
|
+
raise StepCommandError(
|
|
125
|
+
"step command inputs must be a logical-reference mapping"
|
|
126
|
+
)
|
|
117
127
|
normalized: dict[str, pathlib.Path] = {}
|
|
118
128
|
for logical_ref, path in inputs.items():
|
|
119
129
|
if not isinstance(logical_ref, str) or not logical_ref:
|
|
120
|
-
raise StepCommandError(
|
|
130
|
+
raise StepCommandError(
|
|
131
|
+
"step command input references must be non-empty strings"
|
|
132
|
+
)
|
|
121
133
|
if not isinstance(path, pathlib.Path):
|
|
122
134
|
path = pathlib.Path(path)
|
|
123
135
|
try:
|
|
124
136
|
resolved = path.resolve(strict=True)
|
|
125
137
|
metadata = resolved.stat()
|
|
126
138
|
except (OSError, RuntimeError) as exc:
|
|
127
|
-
raise StepCommandError(
|
|
139
|
+
raise StepCommandError(
|
|
140
|
+
"step command input artifact could not be opened"
|
|
141
|
+
) from exc
|
|
128
142
|
if path_is_link_or_reparse(path) or not resolved.is_file() or not metadata:
|
|
129
143
|
raise StepCommandError("step command input artifact must be a regular file")
|
|
130
144
|
normalized[logical_ref] = resolved
|
|
@@ -155,7 +169,9 @@ def _substitute_argv(
|
|
|
155
169
|
)
|
|
156
170
|
rendered.append(str(inputs[logical_ref]))
|
|
157
171
|
else:
|
|
158
|
-
raise StepCommandError(
|
|
172
|
+
raise StepCommandError(
|
|
173
|
+
f"unknown step command placeholder in argument {index}"
|
|
174
|
+
)
|
|
159
175
|
continue
|
|
160
176
|
|
|
161
177
|
if _RECOGNIZED_TOKEN.search(argument):
|
|
@@ -178,12 +194,16 @@ def resolve_step_command_argv(
|
|
|
178
194
|
try:
|
|
179
195
|
root = pathlib.Path(repo_root).resolve(strict=True)
|
|
180
196
|
except (OSError, RuntimeError) as exc:
|
|
181
|
-
raise StepCommandError(
|
|
197
|
+
raise StepCommandError(
|
|
198
|
+
"step command repository root could not be resolved"
|
|
199
|
+
) from exc
|
|
182
200
|
if not root.is_dir():
|
|
183
201
|
raise StepCommandError("step command repository root must be a directory")
|
|
184
202
|
executable = python_executable or sys.executable
|
|
185
203
|
if not isinstance(executable, str) or not executable or "\x00" in executable:
|
|
186
|
-
raise StepCommandError(
|
|
204
|
+
raise StepCommandError(
|
|
205
|
+
"step command Python interpreter must be a non-empty NUL-free string"
|
|
206
|
+
)
|
|
187
207
|
return _substitute_argv(
|
|
188
208
|
command,
|
|
189
209
|
repo_root=root,
|
|
@@ -222,7 +242,9 @@ def _validate_utf8(result: StepCommandResult) -> None:
|
|
|
222
242
|
try:
|
|
223
243
|
stream.decode("utf-8")
|
|
224
244
|
except UnicodeDecodeError as exc:
|
|
225
|
-
error = StepCommandEncodingError(
|
|
245
|
+
error = StepCommandEncodingError(
|
|
246
|
+
f"step command emitted invalid UTF-8 on {name}"
|
|
247
|
+
)
|
|
226
248
|
error._step_command_result = result
|
|
227
249
|
raise error from exc
|
|
228
250
|
|
|
@@ -245,17 +267,27 @@ def run_step_command(
|
|
|
245
267
|
their private ``_process_result`` attribute for the persistence helper.
|
|
246
268
|
"""
|
|
247
269
|
|
|
248
|
-
if isinstance(timeout_seconds, bool) or not isinstance(
|
|
270
|
+
if isinstance(timeout_seconds, bool) or not isinstance(
|
|
271
|
+
timeout_seconds, (int, float)
|
|
272
|
+
):
|
|
249
273
|
raise StepCommandError("step command timeout must be a finite positive number")
|
|
250
274
|
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
|
251
275
|
raise StepCommandError("step command timeout must be a finite positive number")
|
|
252
|
-
if
|
|
253
|
-
|
|
276
|
+
if (
|
|
277
|
+
isinstance(max_output_bytes, bool)
|
|
278
|
+
or not isinstance(max_output_bytes, int)
|
|
279
|
+
or max_output_bytes < 0
|
|
280
|
+
):
|
|
281
|
+
raise StepCommandError(
|
|
282
|
+
"step command output bound must be a non-negative integer"
|
|
283
|
+
)
|
|
254
284
|
|
|
255
285
|
input_paths = _validated_inputs(inputs)
|
|
256
286
|
if stdin is not None:
|
|
257
287
|
if not isinstance(stdin, str) or stdin not in input_paths:
|
|
258
|
-
raise StepCommandError(
|
|
288
|
+
raise StepCommandError(
|
|
289
|
+
"step command stdin must exactly match a declared input"
|
|
290
|
+
)
|
|
259
291
|
input_path = input_paths[stdin]
|
|
260
292
|
else:
|
|
261
293
|
input_path = None
|
|
@@ -283,7 +315,9 @@ def run_step_command(
|
|
|
283
315
|
)
|
|
284
316
|
if truncated
|
|
285
317
|
)
|
|
286
|
-
error = StepCommandTruncatedError(
|
|
318
|
+
error = StepCommandTruncatedError(
|
|
319
|
+
f"step command {streams} exceeded its capture limit"
|
|
320
|
+
)
|
|
287
321
|
error._step_command_result = result
|
|
288
322
|
raise error
|
|
289
323
|
_validate_utf8(result)
|
|
@@ -307,7 +341,10 @@ def _assert_message(result: StepCommandResult) -> str:
|
|
|
307
341
|
stderr = result.stderr.decode("utf-8", errors="surrogateescape")
|
|
308
342
|
combined = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
|
|
309
343
|
safe = redact_diagnostic(combined, secrets=_environment_secrets())
|
|
310
|
-
return
|
|
344
|
+
return (
|
|
345
|
+
bounded_diagnostic(safe, max_chars=1_200)
|
|
346
|
+
or f"command exited with status {result.returncode}"
|
|
347
|
+
)
|
|
311
348
|
|
|
312
349
|
|
|
313
350
|
def step_command_result_payload(
|
|
@@ -392,7 +429,9 @@ def execute_step_command(
|
|
|
392
429
|
"""
|
|
393
430
|
|
|
394
431
|
if step.type not in {"exec", "assert"}:
|
|
395
|
-
raise StepCommandError(
|
|
432
|
+
raise StepCommandError(
|
|
433
|
+
"step command implementation requires an exec or assert step"
|
|
434
|
+
)
|
|
396
435
|
if isinstance(inputs, Mapping):
|
|
397
436
|
input_map = dict(inputs)
|
|
398
437
|
declared_references = set(step.inputs)
|
|
@@ -402,7 +441,9 @@ def execute_step_command(
|
|
|
402
441
|
)
|
|
403
442
|
else:
|
|
404
443
|
if len(inputs) != len(step.inputs):
|
|
405
|
-
raise StepCommandError(
|
|
444
|
+
raise StepCommandError(
|
|
445
|
+
"step command input paths do not match declared inputs"
|
|
446
|
+
)
|
|
406
447
|
input_map = dict(zip(step.inputs, inputs))
|
|
407
448
|
store = artifacts or ArtifactStore(context.run_dir)
|
|
408
449
|
store.prepare()
|
|
@@ -439,9 +480,13 @@ def execute_step_command(
|
|
|
439
480
|
raise
|
|
440
481
|
|
|
441
482
|
payload = step_command_result_payload(process_result, step_type=step.type)
|
|
442
|
-
persisted = _persist_process_result(
|
|
483
|
+
persisted = _persist_process_result(
|
|
484
|
+
store, state, step, process_result, payload=payload
|
|
485
|
+
)
|
|
443
486
|
if step.type == "exec" and process_result.returncode != 0:
|
|
444
|
-
error = StepCommandExecutionError(
|
|
487
|
+
error = StepCommandExecutionError(
|
|
488
|
+
"step exec command returned a non-zero status"
|
|
489
|
+
)
|
|
445
490
|
error._step_command_result = process_result
|
|
446
491
|
error._step_command_persisted = persisted
|
|
447
492
|
raise error
|