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,473 @@
|
|
|
1
|
+
"""Execution of configuration-defined exec and assert step commands.
|
|
2
|
+
|
|
3
|
+
This module is deliberately independent from the workflow engine. It resolves
|
|
4
|
+
the small command contract, delegates process lifecycle and bounded capture to
|
|
5
|
+
the common runner process utility, and exposes a persistence helper for the
|
|
6
|
+
engine integration lane.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import pathlib
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..artifacts import ArtifactStore
|
|
21
|
+
from ..paths import path_is_link_or_reparse
|
|
22
|
+
from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
|
|
23
|
+
from .runners.contracts import bounded_diagnostic, redact_diagnostic
|
|
24
|
+
from .runners.process import (
|
|
25
|
+
DEFAULT_MAX_OUTPUT_BYTES,
|
|
26
|
+
ProcessResult,
|
|
27
|
+
run_process,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
|
|
31
|
+
STEP_COMMAND_STDOUT_ARTIFACT = "stdout.txt"
|
|
32
|
+
STEP_COMMAND_STDERR_ARTIFACT = "stderr.txt"
|
|
33
|
+
STEP_COMMAND_RESULT_ARTIFACT = "result.json"
|
|
34
|
+
_WHOLE_TOKEN = re.compile(r"\{([A-Za-z0-9_./:-]+)\}\Z")
|
|
35
|
+
_RECOGNIZED_TOKEN = re.compile(r"\{(?:repo|python|input:[A-Za-z0-9_./:-]+)\}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StepCommandError(HookError):
|
|
39
|
+
"""Base class for safe step-command validation and normalization errors."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StepCommandOutputError(StepCommandError):
|
|
43
|
+
"""The command exceeded its bounded output or emitted malformed text."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StepCommandEncodingError(StepCommandOutputError):
|
|
47
|
+
"""A captured command stream was not valid UTF-8."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StepCommandTruncatedError(StepCommandOutputError):
|
|
51
|
+
"""A captured command stream reached the per-stream bound."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class StepCommandExecutionError(StepCommandError):
|
|
55
|
+
"""An exec command returned a non-zero status."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class StepCommandAssertionError(StepCommandError):
|
|
59
|
+
"""An assert command returned a non-zero status after its report was saved."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, repr=False)
|
|
63
|
+
class StepCommandResult:
|
|
64
|
+
"""The exact bounded bytes and process status from one command invocation."""
|
|
65
|
+
|
|
66
|
+
returncode: int
|
|
67
|
+
stdout: bytes = field(repr=False)
|
|
68
|
+
stderr: bytes = field(repr=False)
|
|
69
|
+
stdout_truncated: bool = False
|
|
70
|
+
stderr_truncated: bool = False
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if isinstance(self.returncode, bool) or not isinstance(self.returncode, int):
|
|
74
|
+
raise TypeError("step command returncode must be an integer")
|
|
75
|
+
if not isinstance(self.stdout, bytes) or not isinstance(self.stderr, bytes):
|
|
76
|
+
raise TypeError("step command streams must be bytes")
|
|
77
|
+
if type(self.stdout_truncated) is not bool or type(self.stderr_truncated) is not bool:
|
|
78
|
+
raise TypeError("step command truncation flags must be booleans")
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return (
|
|
82
|
+
"StepCommandResult("
|
|
83
|
+
f"returncode={self.returncode!r}, stdout=<redacted>, stderr=<redacted>, "
|
|
84
|
+
f"stdout_truncated={self.stdout_truncated!r}, "
|
|
85
|
+
f"stderr_truncated={self.stderr_truncated!r})"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class PersistedStepCommandResult:
|
|
91
|
+
"""Result payload and private artifact paths prepared for engine dispatch."""
|
|
92
|
+
|
|
93
|
+
result: dict[str, Any]
|
|
94
|
+
artifacts: Mapping[str, pathlib.Path]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_command(command: Sequence[str]) -> tuple[str, ...]:
|
|
98
|
+
if not isinstance(command, (tuple, list)) or not command:
|
|
99
|
+
raise StepCommandError("step command must be a non-empty argv vector")
|
|
100
|
+
normalized: list[str] = []
|
|
101
|
+
for index, argument in enumerate(command, start=1):
|
|
102
|
+
if not isinstance(argument, str) or not argument or "\x00" in argument:
|
|
103
|
+
raise StepCommandError(
|
|
104
|
+
f"step command argument {index} must be a non-empty NUL-free string"
|
|
105
|
+
)
|
|
106
|
+
normalized.append(argument)
|
|
107
|
+
return tuple(normalized)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validated_inputs(
|
|
111
|
+
inputs: Mapping[str, pathlib.Path] | None,
|
|
112
|
+
) -> dict[str, pathlib.Path]:
|
|
113
|
+
if inputs is None:
|
|
114
|
+
return {}
|
|
115
|
+
if not isinstance(inputs, Mapping):
|
|
116
|
+
raise StepCommandError("step command inputs must be a logical-reference mapping")
|
|
117
|
+
normalized: dict[str, pathlib.Path] = {}
|
|
118
|
+
for logical_ref, path in inputs.items():
|
|
119
|
+
if not isinstance(logical_ref, str) or not logical_ref:
|
|
120
|
+
raise StepCommandError("step command input references must be non-empty strings")
|
|
121
|
+
if not isinstance(path, pathlib.Path):
|
|
122
|
+
path = pathlib.Path(path)
|
|
123
|
+
try:
|
|
124
|
+
resolved = path.resolve(strict=True)
|
|
125
|
+
metadata = resolved.stat()
|
|
126
|
+
except (OSError, RuntimeError) as exc:
|
|
127
|
+
raise StepCommandError("step command input artifact could not be opened") from exc
|
|
128
|
+
if path_is_link_or_reparse(path) or not resolved.is_file() or not metadata:
|
|
129
|
+
raise StepCommandError("step command input artifact must be a regular file")
|
|
130
|
+
normalized[logical_ref] = resolved
|
|
131
|
+
return normalized
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _substitute_argv(
|
|
135
|
+
command: Sequence[str],
|
|
136
|
+
*,
|
|
137
|
+
repo_root: pathlib.Path,
|
|
138
|
+
inputs: Mapping[str, pathlib.Path],
|
|
139
|
+
python_executable: str,
|
|
140
|
+
) -> tuple[str, ...]:
|
|
141
|
+
rendered: list[str] = []
|
|
142
|
+
for index, argument in enumerate(_validate_command(command), start=1):
|
|
143
|
+
match = _WHOLE_TOKEN.fullmatch(argument)
|
|
144
|
+
if match is not None:
|
|
145
|
+
token = match.group(1)
|
|
146
|
+
if token == "repo":
|
|
147
|
+
rendered.append(str(repo_root))
|
|
148
|
+
elif token == "python":
|
|
149
|
+
rendered.append(python_executable)
|
|
150
|
+
elif token.startswith("input:"):
|
|
151
|
+
logical_ref = token.removeprefix("input:")
|
|
152
|
+
if logical_ref not in inputs:
|
|
153
|
+
raise StepCommandError(
|
|
154
|
+
f"step command argument {index} references an undeclared input"
|
|
155
|
+
)
|
|
156
|
+
rendered.append(str(inputs[logical_ref]))
|
|
157
|
+
else:
|
|
158
|
+
raise StepCommandError(f"unknown step command placeholder in argument {index}")
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if _RECOGNIZED_TOKEN.search(argument):
|
|
162
|
+
raise StepCommandError(
|
|
163
|
+
f"recognized step command placeholders must be whole argv elements (argument {index})"
|
|
164
|
+
)
|
|
165
|
+
rendered.append(argument)
|
|
166
|
+
return tuple(rendered)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_step_command_argv(
|
|
170
|
+
command: Sequence[str],
|
|
171
|
+
repo_root: pathlib.Path,
|
|
172
|
+
inputs: Mapping[str, pathlib.Path] | None = None,
|
|
173
|
+
*,
|
|
174
|
+
python_executable: str | None = None,
|
|
175
|
+
) -> tuple[str, ...]:
|
|
176
|
+
"""Resolve reserved placeholders without parsing or invoking a shell."""
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
root = pathlib.Path(repo_root).resolve(strict=True)
|
|
180
|
+
except (OSError, RuntimeError) as exc:
|
|
181
|
+
raise StepCommandError("step command repository root could not be resolved") from exc
|
|
182
|
+
if not root.is_dir():
|
|
183
|
+
raise StepCommandError("step command repository root must be a directory")
|
|
184
|
+
executable = python_executable or sys.executable
|
|
185
|
+
if not isinstance(executable, str) or not executable or "\x00" in executable:
|
|
186
|
+
raise StepCommandError("step command Python interpreter must be a non-empty NUL-free string")
|
|
187
|
+
return _substitute_argv(
|
|
188
|
+
command,
|
|
189
|
+
repo_root=root,
|
|
190
|
+
inputs=_validated_inputs(inputs),
|
|
191
|
+
python_executable=executable,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _from_process_result(result: ProcessResult) -> StepCommandResult:
|
|
196
|
+
stdout_bytes = result.stdout_bytes
|
|
197
|
+
stderr_bytes = result.stderr_bytes
|
|
198
|
+
# Test doubles and older in-process callers may construct ProcessResult
|
|
199
|
+
# without the new byte fields. Surrogateescape preserves their text bytes.
|
|
200
|
+
if not stdout_bytes and result.stdout:
|
|
201
|
+
stdout_bytes = result.stdout.encode("utf-8", errors="surrogateescape")
|
|
202
|
+
if not stderr_bytes and result.stderr:
|
|
203
|
+
stderr_bytes = result.stderr.encode("utf-8", errors="surrogateescape")
|
|
204
|
+
return StepCommandResult(
|
|
205
|
+
returncode=result.returncode,
|
|
206
|
+
stdout=stdout_bytes,
|
|
207
|
+
stderr=stderr_bytes,
|
|
208
|
+
stdout_truncated=result.stdout_truncated,
|
|
209
|
+
stderr_truncated=result.stderr_truncated,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _result_from_error(error: BaseException) -> StepCommandResult | None:
|
|
214
|
+
process_result = getattr(error, "_process_result", None)
|
|
215
|
+
if not isinstance(process_result, ProcessResult):
|
|
216
|
+
return None
|
|
217
|
+
return _from_process_result(process_result)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _validate_utf8(result: StepCommandResult) -> None:
|
|
221
|
+
for name, stream in (("stdout", result.stdout), ("stderr", result.stderr)):
|
|
222
|
+
try:
|
|
223
|
+
stream.decode("utf-8")
|
|
224
|
+
except UnicodeDecodeError as exc:
|
|
225
|
+
error = StepCommandEncodingError(f"step command emitted invalid UTF-8 on {name}")
|
|
226
|
+
error._step_command_result = result
|
|
227
|
+
raise error from exc
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run_step_command(
|
|
231
|
+
command: Sequence[str],
|
|
232
|
+
repo_root: pathlib.Path,
|
|
233
|
+
*,
|
|
234
|
+
inputs: Mapping[str, pathlib.Path] | None = None,
|
|
235
|
+
stdin: str | None = None,
|
|
236
|
+
timeout_seconds: float = DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS,
|
|
237
|
+
python_executable: str | None = None,
|
|
238
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
239
|
+
) -> StepCommandResult:
|
|
240
|
+
"""Run an exec/assert command and return bounded exact output bytes.
|
|
241
|
+
|
|
242
|
+
The child receives the invoking environment, runs at the canonical
|
|
243
|
+
repository root, and receives EOF unless ``stdin`` names an input artifact.
|
|
244
|
+
Process timeout/signal/missing-executable errors retain captured output on
|
|
245
|
+
their private ``_process_result`` attribute for the persistence helper.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)):
|
|
249
|
+
raise StepCommandError("step command timeout must be a finite positive number")
|
|
250
|
+
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
|
251
|
+
raise StepCommandError("step command timeout must be a finite positive number")
|
|
252
|
+
if isinstance(max_output_bytes, bool) or not isinstance(max_output_bytes, int) or max_output_bytes < 0:
|
|
253
|
+
raise StepCommandError("step command output bound must be a non-negative integer")
|
|
254
|
+
|
|
255
|
+
input_paths = _validated_inputs(inputs)
|
|
256
|
+
if stdin is not None:
|
|
257
|
+
if not isinstance(stdin, str) or stdin not in input_paths:
|
|
258
|
+
raise StepCommandError("step command stdin must exactly match a declared input")
|
|
259
|
+
input_path = input_paths[stdin]
|
|
260
|
+
else:
|
|
261
|
+
input_path = None
|
|
262
|
+
argv = _substitute_argv(
|
|
263
|
+
command,
|
|
264
|
+
repo_root=pathlib.Path(repo_root).resolve(strict=True),
|
|
265
|
+
inputs=input_paths,
|
|
266
|
+
python_executable=python_executable or sys.executable,
|
|
267
|
+
)
|
|
268
|
+
process_result = run_process(
|
|
269
|
+
argv,
|
|
270
|
+
cwd=pathlib.Path(repo_root).resolve(strict=True),
|
|
271
|
+
input_path=input_path,
|
|
272
|
+
timeout_seconds=timeout_seconds,
|
|
273
|
+
env=None,
|
|
274
|
+
max_output_bytes=max_output_bytes,
|
|
275
|
+
)
|
|
276
|
+
result = _from_process_result(process_result)
|
|
277
|
+
if result.stdout_truncated or result.stderr_truncated:
|
|
278
|
+
streams = " and ".join(
|
|
279
|
+
name
|
|
280
|
+
for name, truncated in (
|
|
281
|
+
("stdout", result.stdout_truncated),
|
|
282
|
+
("stderr", result.stderr_truncated),
|
|
283
|
+
)
|
|
284
|
+
if truncated
|
|
285
|
+
)
|
|
286
|
+
error = StepCommandTruncatedError(f"step command {streams} exceeded its capture limit")
|
|
287
|
+
error._step_command_result = result
|
|
288
|
+
raise error
|
|
289
|
+
_validate_utf8(result)
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _environment_secrets() -> tuple[str, ...]:
|
|
294
|
+
markers = ("API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "CREDENTIAL")
|
|
295
|
+
return tuple(
|
|
296
|
+
value
|
|
297
|
+
for name, value in os.environ.items()
|
|
298
|
+
if any(marker in name.upper() for marker in markers) and value
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _assert_message(result: StepCommandResult) -> str:
|
|
303
|
+
# A process error can occur before the normal strict UTF-8 validation pass.
|
|
304
|
+
# Surrogateescape keeps that diagnostic bounded and non-throwing; the
|
|
305
|
+
# private stream artifacts still retain the exact original bytes.
|
|
306
|
+
stdout = result.stdout.decode("utf-8", errors="surrogateescape")
|
|
307
|
+
stderr = result.stderr.decode("utf-8", errors="surrogateescape")
|
|
308
|
+
combined = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
|
|
309
|
+
safe = redact_diagnostic(combined, secrets=_environment_secrets())
|
|
310
|
+
return bounded_diagnostic(safe, max_chars=1_200) or f"command exited with status {result.returncode}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def step_command_result_payload(
|
|
314
|
+
result: StepCommandResult,
|
|
315
|
+
*,
|
|
316
|
+
step_type: str,
|
|
317
|
+
stdout_artifact: str = STEP_COMMAND_STDOUT_ARTIFACT,
|
|
318
|
+
stderr_artifact: str = STEP_COMMAND_STDERR_ARTIFACT,
|
|
319
|
+
) -> dict[str, Any]:
|
|
320
|
+
"""Normalize a successful or process-started command into result.json data."""
|
|
321
|
+
|
|
322
|
+
if step_type not in {"exec", "assert"}:
|
|
323
|
+
raise StepCommandError("step command implementation requires exec or assert")
|
|
324
|
+
payload: dict[str, Any] = {
|
|
325
|
+
"returncode": result.returncode,
|
|
326
|
+
"stdout_artifact": stdout_artifact,
|
|
327
|
+
"stderr_artifact": stderr_artifact,
|
|
328
|
+
"stdout_truncated": result.stdout_truncated,
|
|
329
|
+
"stderr_truncated": result.stderr_truncated,
|
|
330
|
+
}
|
|
331
|
+
if step_type == "assert":
|
|
332
|
+
payload["ok"] = result.returncode == 0
|
|
333
|
+
if result.returncode != 0:
|
|
334
|
+
payload["message"] = _assert_message(result)
|
|
335
|
+
return payload
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _persist_process_result(
|
|
339
|
+
store: ArtifactStore,
|
|
340
|
+
state: ModuleRuntimeState,
|
|
341
|
+
step: StepConfig,
|
|
342
|
+
result: StepCommandResult,
|
|
343
|
+
*,
|
|
344
|
+
payload: dict[str, Any] | None = None,
|
|
345
|
+
) -> PersistedStepCommandResult:
|
|
346
|
+
stdout_path = store.write_bytes(
|
|
347
|
+
state,
|
|
348
|
+
state.step_index,
|
|
349
|
+
step.id,
|
|
350
|
+
STEP_COMMAND_STDOUT_ARTIFACT,
|
|
351
|
+
result.stdout,
|
|
352
|
+
)
|
|
353
|
+
stderr_path = store.write_bytes(
|
|
354
|
+
state,
|
|
355
|
+
state.step_index,
|
|
356
|
+
step.id,
|
|
357
|
+
STEP_COMMAND_STDERR_ARTIFACT,
|
|
358
|
+
result.stderr,
|
|
359
|
+
)
|
|
360
|
+
report = payload or step_command_result_payload(result, step_type=step.type)
|
|
361
|
+
report_path = store.write_json(
|
|
362
|
+
state,
|
|
363
|
+
state.step_index,
|
|
364
|
+
step.id,
|
|
365
|
+
STEP_COMMAND_RESULT_ARTIFACT,
|
|
366
|
+
report,
|
|
367
|
+
)
|
|
368
|
+
return PersistedStepCommandResult(
|
|
369
|
+
result=report,
|
|
370
|
+
artifacts={
|
|
371
|
+
STEP_COMMAND_STDOUT_ARTIFACT: stdout_path,
|
|
372
|
+
STEP_COMMAND_STDERR_ARTIFACT: stderr_path,
|
|
373
|
+
STEP_COMMAND_RESULT_ARTIFACT: report_path,
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def execute_step_command(
|
|
379
|
+
context: RuntimeContext,
|
|
380
|
+
state: ModuleRuntimeState,
|
|
381
|
+
step: StepConfig,
|
|
382
|
+
inputs: Mapping[str, pathlib.Path] | Sequence[pathlib.Path],
|
|
383
|
+
*,
|
|
384
|
+
artifacts: ArtifactStore | None = None,
|
|
385
|
+
) -> PersistedStepCommandResult:
|
|
386
|
+
"""Run and persist one configured command for future engine dispatch.
|
|
387
|
+
|
|
388
|
+
The report and both private stream artifacts are written before an exec
|
|
389
|
+
non-zero or assert false result raises. Process errors retain their
|
|
390
|
+
bounded streams and are persisted when a process started; a missing
|
|
391
|
+
executable has no process output and therefore creates no artifacts.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
if step.type not in {"exec", "assert"}:
|
|
395
|
+
raise StepCommandError("step command implementation requires an exec or assert step")
|
|
396
|
+
if isinstance(inputs, Mapping):
|
|
397
|
+
input_map = dict(inputs)
|
|
398
|
+
declared_references = set(step.inputs)
|
|
399
|
+
if set(input_map) != declared_references:
|
|
400
|
+
raise StepCommandError(
|
|
401
|
+
"step command input mapping must contain exactly the declared references"
|
|
402
|
+
)
|
|
403
|
+
else:
|
|
404
|
+
if len(inputs) != len(step.inputs):
|
|
405
|
+
raise StepCommandError("step command input paths do not match declared inputs")
|
|
406
|
+
input_map = dict(zip(step.inputs, inputs))
|
|
407
|
+
store = artifacts or ArtifactStore(context.run_dir)
|
|
408
|
+
store.prepare()
|
|
409
|
+
try:
|
|
410
|
+
process_result = run_step_command(
|
|
411
|
+
step.command,
|
|
412
|
+
context.repo_root,
|
|
413
|
+
inputs=input_map,
|
|
414
|
+
stdin=step.stdin,
|
|
415
|
+
timeout_seconds=(
|
|
416
|
+
step.timeout_seconds
|
|
417
|
+
if step.timeout_seconds is not None
|
|
418
|
+
else DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
except BaseException as error:
|
|
422
|
+
captured = _result_from_error(error)
|
|
423
|
+
if captured is None:
|
|
424
|
+
captured = getattr(error, "_step_command_result", None)
|
|
425
|
+
if isinstance(captured, StepCommandResult):
|
|
426
|
+
report = None
|
|
427
|
+
if isinstance(error, StepCommandEncodingError):
|
|
428
|
+
report = step_command_result_payload(captured, step_type=step.type)
|
|
429
|
+
report["malformed"] = True
|
|
430
|
+
report["message"] = "command output was not valid UTF-8"
|
|
431
|
+
persisted = _persist_process_result(
|
|
432
|
+
store,
|
|
433
|
+
state,
|
|
434
|
+
step,
|
|
435
|
+
captured,
|
|
436
|
+
payload=report,
|
|
437
|
+
)
|
|
438
|
+
error._step_command_persisted = persisted
|
|
439
|
+
raise
|
|
440
|
+
|
|
441
|
+
payload = step_command_result_payload(process_result, step_type=step.type)
|
|
442
|
+
persisted = _persist_process_result(store, state, step, process_result, payload=payload)
|
|
443
|
+
if step.type == "exec" and process_result.returncode != 0:
|
|
444
|
+
error = StepCommandExecutionError("step exec command returned a non-zero status")
|
|
445
|
+
error._step_command_result = process_result
|
|
446
|
+
error._step_command_persisted = persisted
|
|
447
|
+
raise error
|
|
448
|
+
if step.type == "assert" and process_result.returncode != 0:
|
|
449
|
+
error = StepCommandAssertionError(str(payload["message"]))
|
|
450
|
+
error._step_command_result = process_result
|
|
451
|
+
error._step_command_persisted = persisted
|
|
452
|
+
raise error
|
|
453
|
+
return persisted
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
__all__ = [
|
|
457
|
+
"DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS",
|
|
458
|
+
"STEP_COMMAND_RESULT_ARTIFACT",
|
|
459
|
+
"STEP_COMMAND_STDERR_ARTIFACT",
|
|
460
|
+
"STEP_COMMAND_STDOUT_ARTIFACT",
|
|
461
|
+
"PersistedStepCommandResult",
|
|
462
|
+
"StepCommandAssertionError",
|
|
463
|
+
"StepCommandEncodingError",
|
|
464
|
+
"StepCommandError",
|
|
465
|
+
"StepCommandExecutionError",
|
|
466
|
+
"StepCommandOutputError",
|
|
467
|
+
"StepCommandResult",
|
|
468
|
+
"StepCommandTruncatedError",
|
|
469
|
+
"execute_step_command",
|
|
470
|
+
"resolve_step_command_argv",
|
|
471
|
+
"run_step_command",
|
|
472
|
+
"step_command_result_payload",
|
|
473
|
+
]
|