engineering-process 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.
- engineering_process/__init__.py +3 -0
- engineering_process/_download_worker.py +73 -0
- engineering_process/_supervisor_posix.py +162 -0
- engineering_process/_supervisor_windows.py +148 -0
- engineering_process/_windows_job.py +419 -0
- engineering_process/bootstrap.py +217 -0
- engineering_process/bundles.py +81 -0
- engineering_process/cli.py +1012 -0
- engineering_process/contracts.py +1274 -0
- engineering_process/distribution.py +90 -0
- engineering_process/environment.py +753 -0
- engineering_process/helper_launch.py +37 -0
- engineering_process/lifecycle.py +1024 -0
- engineering_process/managed.py +89 -0
- engineering_process/markdown.py +78 -0
- engineering_process/publication.py +463 -0
- engineering_process/requirements-build.txt +1 -0
- engineering_process/requirements-dev.txt +10 -0
- engineering_process/requirements-runtime.txt +3 -0
- engineering_process/runner.py +191 -0
- engineering_process/runtime.py +55 -0
- engineering_process/skills.py +118 -0
- engineering_process/supervision.py +72 -0
- engineering_process/syncing.py +398 -0
- engineering_process/tooling.py +891 -0
- engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
- engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
- engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
- engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
- engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
- engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
- engineering_process-0.1.0.dist-info/METADATA +387 -0
- engineering_process-0.1.0.dist-info/RECORD +69 -0
- engineering_process-0.1.0.dist-info/WHEEL +5 -0
- engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
- engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
- engineering_process-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import regex as bounded_regex
|
|
15
|
+
|
|
16
|
+
from .contracts import (
|
|
17
|
+
ContractError,
|
|
18
|
+
EnvironmentRequirement,
|
|
19
|
+
MUTATION_SCOPES,
|
|
20
|
+
Project,
|
|
21
|
+
ProjectEnvironment,
|
|
22
|
+
SetupAction,
|
|
23
|
+
)
|
|
24
|
+
from .tooling import (
|
|
25
|
+
ManagedCommandBinding,
|
|
26
|
+
install_managed_tool,
|
|
27
|
+
installed_command_bindings,
|
|
28
|
+
managed_command_bindings,
|
|
29
|
+
managed_tool_preflight,
|
|
30
|
+
managed_path_entries,
|
|
31
|
+
selected_artifact,
|
|
32
|
+
)
|
|
33
|
+
from .supervision import process_supervisor
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
OUTPUT_LIMIT = 16_384
|
|
37
|
+
OUTPUT_REGEX_TIMEOUT_SECONDS = 0.1
|
|
38
|
+
TERMINATION_GRACE_SECONDS = 1.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _timestamp() -> str:
|
|
42
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _contained_working_directory(root: Path, relative: str) -> Path:
|
|
46
|
+
resolved_root = root.resolve()
|
|
47
|
+
working = (resolved_root / relative).resolve()
|
|
48
|
+
try:
|
|
49
|
+
working.relative_to(resolved_root)
|
|
50
|
+
except ValueError as error:
|
|
51
|
+
raise ContractError(f"working directory escapes the project root: {relative}") from error
|
|
52
|
+
if not working.is_dir():
|
|
53
|
+
raise ContractError(f"working directory does not exist: {relative}")
|
|
54
|
+
return working
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _command_preflight(
|
|
58
|
+
root: Path,
|
|
59
|
+
*,
|
|
60
|
+
identifier: str,
|
|
61
|
+
run: tuple[str, ...],
|
|
62
|
+
working_directory: str,
|
|
63
|
+
path_entries: tuple[Path, ...] = (),
|
|
64
|
+
command_bindings: dict[str, ManagedCommandBinding] | None = None,
|
|
65
|
+
planned_commands: set[str] | None = None,
|
|
66
|
+
) -> str | None:
|
|
67
|
+
working = _contained_working_directory(root, working_directory)
|
|
68
|
+
command_environment = os.environ.copy()
|
|
69
|
+
command_environment["PATH"] = os.pathsep.join(
|
|
70
|
+
[*(str(path) for path in path_entries), os.environ.get("PATH", "")]
|
|
71
|
+
)
|
|
72
|
+
effective_run = run
|
|
73
|
+
if binding := (command_bindings or {}).get(run[0]):
|
|
74
|
+
effective_run = (
|
|
75
|
+
str(binding.application),
|
|
76
|
+
*binding.prefix_arguments,
|
|
77
|
+
*run[1:],
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
process_supervisor().resolve_application(
|
|
81
|
+
effective_run[0],
|
|
82
|
+
working_directory=working,
|
|
83
|
+
environment=command_environment,
|
|
84
|
+
)
|
|
85
|
+
except OSError:
|
|
86
|
+
if run[0] not in (planned_commands or set()):
|
|
87
|
+
return f"setup action {identifier}: executable is unavailable: {run[0]}"
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_OUTPUT_MIRROR_LOCK = threading.Lock()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _drain_output(stream, capture: dict[str, Any], *, mirror: bool) -> None:
|
|
95
|
+
try:
|
|
96
|
+
while chunk := stream.read(8192):
|
|
97
|
+
remaining = OUTPUT_LIMIT - len(capture["data"])
|
|
98
|
+
if remaining > 0:
|
|
99
|
+
capture["data"].extend(chunk[:remaining])
|
|
100
|
+
if len(chunk) > remaining:
|
|
101
|
+
capture["truncated"] = True
|
|
102
|
+
if mirror:
|
|
103
|
+
with _OUTPUT_MIRROR_LOCK:
|
|
104
|
+
binary = getattr(sys.stderr, "buffer", None)
|
|
105
|
+
if binary is not None:
|
|
106
|
+
binary.write(chunk)
|
|
107
|
+
binary.flush()
|
|
108
|
+
else:
|
|
109
|
+
sys.stderr.write(chunk.decode("utf-8", errors="replace"))
|
|
110
|
+
sys.stderr.flush()
|
|
111
|
+
except (OSError, ValueError):
|
|
112
|
+
capture["truncated"] = True
|
|
113
|
+
finally:
|
|
114
|
+
stream.close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def execute_command(
|
|
118
|
+
root: Path,
|
|
119
|
+
*,
|
|
120
|
+
identifier: str,
|
|
121
|
+
run: tuple[str, ...],
|
|
122
|
+
timeout_seconds: int,
|
|
123
|
+
working_directory: str,
|
|
124
|
+
path_entries: tuple[Path, ...] = (),
|
|
125
|
+
command_bindings: dict[str, ManagedCommandBinding] | None = None,
|
|
126
|
+
stream_output: bool = False,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
working = _contained_working_directory(root, working_directory)
|
|
129
|
+
started = _timestamp()
|
|
130
|
+
monotonic_start = time.monotonic()
|
|
131
|
+
command_digest = hashlib.sha256(
|
|
132
|
+
json.dumps(run, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
133
|
+
).hexdigest()
|
|
134
|
+
command_environment = os.environ.copy()
|
|
135
|
+
if path_entries:
|
|
136
|
+
command_environment["PATH"] = os.pathsep.join(
|
|
137
|
+
[
|
|
138
|
+
*(str(path) for path in path_entries),
|
|
139
|
+
command_environment.get("PATH", ""),
|
|
140
|
+
]
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
supervisor = process_supervisor()
|
|
144
|
+
effective_run = run
|
|
145
|
+
if binding := (command_bindings or {}).get(run[0]):
|
|
146
|
+
effective_run = (
|
|
147
|
+
str(binding.application),
|
|
148
|
+
*binding.prefix_arguments,
|
|
149
|
+
*run[1:],
|
|
150
|
+
)
|
|
151
|
+
process = supervisor.spawn(
|
|
152
|
+
effective_run,
|
|
153
|
+
working_directory=working,
|
|
154
|
+
environment=command_environment,
|
|
155
|
+
)
|
|
156
|
+
except (OSError, ValueError) as error:
|
|
157
|
+
return {
|
|
158
|
+
"id": identifier,
|
|
159
|
+
"status": "failed-to-start",
|
|
160
|
+
"exitCode": None,
|
|
161
|
+
"startedAt": started,
|
|
162
|
+
"durationMs": round((time.monotonic() - monotonic_start) * 1000),
|
|
163
|
+
"workingDirectory": working_directory,
|
|
164
|
+
"command": list(run),
|
|
165
|
+
"commandSha256": command_digest,
|
|
166
|
+
"error": str(error),
|
|
167
|
+
"stdout": "",
|
|
168
|
+
"stderr": "",
|
|
169
|
+
"outputTruncated": False,
|
|
170
|
+
"pathEntries": [str(path) for path in path_entries],
|
|
171
|
+
}
|
|
172
|
+
assert process.stdout is not None
|
|
173
|
+
assert process.stderr is not None
|
|
174
|
+
stdout_capture: dict[str, Any] = {"data": bytearray(), "truncated": False}
|
|
175
|
+
stderr_capture: dict[str, Any] = {"data": bytearray(), "truncated": False}
|
|
176
|
+
drain_threads = (
|
|
177
|
+
threading.Thread(
|
|
178
|
+
target=_drain_output,
|
|
179
|
+
args=(process.stdout, stdout_capture),
|
|
180
|
+
kwargs={"mirror": stream_output},
|
|
181
|
+
daemon=True,
|
|
182
|
+
),
|
|
183
|
+
threading.Thread(
|
|
184
|
+
target=_drain_output,
|
|
185
|
+
args=(process.stderr, stderr_capture),
|
|
186
|
+
kwargs={"mirror": stream_output},
|
|
187
|
+
daemon=True,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
for thread in drain_threads:
|
|
191
|
+
thread.start()
|
|
192
|
+
status = "passed"
|
|
193
|
+
error_message: str | None = None
|
|
194
|
+
command_tree_bounded = True
|
|
195
|
+
try:
|
|
196
|
+
exit_code = process.wait(timeout=timeout_seconds)
|
|
197
|
+
if exit_code != 0:
|
|
198
|
+
status = "failed"
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
status = "timed-out"
|
|
201
|
+
error_message = f"exceeded {timeout_seconds} seconds"
|
|
202
|
+
cleanup = supervisor.terminate(
|
|
203
|
+
process, grace_seconds=TERMINATION_GRACE_SECONDS
|
|
204
|
+
)
|
|
205
|
+
command_tree_bounded = cleanup.bounded
|
|
206
|
+
if cleanup.error is not None:
|
|
207
|
+
error_message = cleanup.error
|
|
208
|
+
exit_code = process.returncode
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
supervisor.terminate(process, grace_seconds=TERMINATION_GRACE_SECONDS)
|
|
211
|
+
raise
|
|
212
|
+
finally:
|
|
213
|
+
if process.poll() is not None:
|
|
214
|
+
cleanup = supervisor.finalize(
|
|
215
|
+
process, grace_seconds=TERMINATION_GRACE_SECONDS
|
|
216
|
+
)
|
|
217
|
+
command_tree_bounded = command_tree_bounded and cleanup.bounded
|
|
218
|
+
if cleanup.descendants_found and status == "passed":
|
|
219
|
+
status = "failed"
|
|
220
|
+
error_message = "command left descendant processes; they were terminated"
|
|
221
|
+
if cleanup.error is not None:
|
|
222
|
+
error_message = cleanup.error
|
|
223
|
+
for thread in drain_threads:
|
|
224
|
+
thread.join(timeout=TERMINATION_GRACE_SECONDS)
|
|
225
|
+
if any(thread.is_alive() for thread in drain_threads):
|
|
226
|
+
status = "failed"
|
|
227
|
+
error_message = (
|
|
228
|
+
"command process group retained output streams after bounded termination"
|
|
229
|
+
)
|
|
230
|
+
if not command_tree_bounded:
|
|
231
|
+
status = "failed"
|
|
232
|
+
error_message = (
|
|
233
|
+
"command process group could not be terminated within the bounded grace period"
|
|
234
|
+
)
|
|
235
|
+
for thread in drain_threads:
|
|
236
|
+
thread.join(timeout=1)
|
|
237
|
+
stdout = bytes(stdout_capture["data"]).decode("utf-8", errors="replace")
|
|
238
|
+
stderr = bytes(stderr_capture["data"]).decode("utf-8", errors="replace")
|
|
239
|
+
result: dict[str, Any] = {
|
|
240
|
+
"id": identifier,
|
|
241
|
+
"status": status,
|
|
242
|
+
"exitCode": exit_code,
|
|
243
|
+
"startedAt": started,
|
|
244
|
+
"durationMs": round((time.monotonic() - monotonic_start) * 1000),
|
|
245
|
+
"workingDirectory": working_directory,
|
|
246
|
+
"command": list(run),
|
|
247
|
+
"commandSha256": command_digest,
|
|
248
|
+
"stdout": stdout,
|
|
249
|
+
"stderr": stderr,
|
|
250
|
+
"outputTruncated": bool(
|
|
251
|
+
stdout_capture["truncated"] or stderr_capture["truncated"]
|
|
252
|
+
),
|
|
253
|
+
"pathEntries": [str(path) for path in path_entries],
|
|
254
|
+
}
|
|
255
|
+
if error_message is not None:
|
|
256
|
+
result["error"] = error_message
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _profile_environment(
|
|
261
|
+
project: Project, profile: str | None
|
|
262
|
+
) -> tuple[ProjectEnvironment | None, str | None, tuple[str, ...]]:
|
|
263
|
+
environment = project.environment
|
|
264
|
+
if environment is None:
|
|
265
|
+
if profile is not None:
|
|
266
|
+
raise ContractError(
|
|
267
|
+
"environment profile requested for an internal Project without an "
|
|
268
|
+
"environment contract"
|
|
269
|
+
)
|
|
270
|
+
return None, None, ()
|
|
271
|
+
selected = profile or environment.default_profile
|
|
272
|
+
requirements = environment.profiles.get(selected)
|
|
273
|
+
if requirements is None:
|
|
274
|
+
available = ", ".join(sorted(environment.profiles))
|
|
275
|
+
raise ContractError(
|
|
276
|
+
f"unknown environment profile {selected}; available profiles: {available}"
|
|
277
|
+
)
|
|
278
|
+
return environment, selected, requirements
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _required_action_ids(
|
|
282
|
+
environment: ProjectEnvironment, requirement_ids: tuple[str, ...]
|
|
283
|
+
) -> set[str]:
|
|
284
|
+
return {
|
|
285
|
+
action
|
|
286
|
+
for identifier in requirement_ids
|
|
287
|
+
if (action := environment.requirements[identifier].setup_action) is not None
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _action_dependencies(
|
|
292
|
+
environment: ProjectEnvironment, identifier: str
|
|
293
|
+
) -> set[str]:
|
|
294
|
+
dependencies: set[str] = set()
|
|
295
|
+
|
|
296
|
+
def add(current: str) -> None:
|
|
297
|
+
for dependency in environment.setup_actions[current].requires:
|
|
298
|
+
if dependency not in dependencies:
|
|
299
|
+
dependencies.add(dependency)
|
|
300
|
+
add(dependency)
|
|
301
|
+
|
|
302
|
+
add(identifier)
|
|
303
|
+
return dependencies
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _environment_managed_tools(
|
|
307
|
+
project: Project, *, profile: str
|
|
308
|
+
) -> tuple[Any, ...]:
|
|
309
|
+
if project.environment is None:
|
|
310
|
+
return ()
|
|
311
|
+
environment, _, identifiers = _profile_environment(project, profile)
|
|
312
|
+
if environment is None:
|
|
313
|
+
return ()
|
|
314
|
+
actions = _action_order(
|
|
315
|
+
environment, _required_action_ids(environment, identifiers)
|
|
316
|
+
)
|
|
317
|
+
tool_ids = {
|
|
318
|
+
action.tool
|
|
319
|
+
for action in actions
|
|
320
|
+
if action.kind == "managed-tool" and action.tool is not None
|
|
321
|
+
}
|
|
322
|
+
return tuple(environment.managed_tools[identifier] for identifier in sorted(tool_ids))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def environment_path_entries(
|
|
326
|
+
project: Project, *, profile: str
|
|
327
|
+
) -> tuple[Path, ...]:
|
|
328
|
+
entries: list[Path] = []
|
|
329
|
+
for tool in _environment_managed_tools(project, profile=profile):
|
|
330
|
+
try:
|
|
331
|
+
tool_entries = managed_path_entries([tool])
|
|
332
|
+
except ContractError:
|
|
333
|
+
continue
|
|
334
|
+
for entry in tool_entries:
|
|
335
|
+
if entry not in entries:
|
|
336
|
+
entries.append(entry)
|
|
337
|
+
return tuple(entries)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def environment_command_bindings(
|
|
341
|
+
project: Project, *, profile: str
|
|
342
|
+
) -> dict[str, ManagedCommandBinding]:
|
|
343
|
+
return managed_command_bindings(
|
|
344
|
+
_environment_managed_tools(project, profile=profile)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _probe_requirement(
|
|
349
|
+
root: Path,
|
|
350
|
+
requirement: EnvironmentRequirement,
|
|
351
|
+
*,
|
|
352
|
+
path_entries: tuple[Path, ...],
|
|
353
|
+
command_bindings: dict[str, ManagedCommandBinding],
|
|
354
|
+
) -> dict[str, Any]:
|
|
355
|
+
probe = requirement.probe
|
|
356
|
+
deadline = time.monotonic() + probe.timeout_seconds
|
|
357
|
+
execution = execute_command(
|
|
358
|
+
root,
|
|
359
|
+
identifier=requirement.identifier,
|
|
360
|
+
run=probe.run,
|
|
361
|
+
timeout_seconds=probe.timeout_seconds,
|
|
362
|
+
working_directory=probe.working_directory,
|
|
363
|
+
path_entries=path_entries,
|
|
364
|
+
command_bindings=command_bindings,
|
|
365
|
+
)
|
|
366
|
+
stream = {
|
|
367
|
+
"stdout": execution["stdout"],
|
|
368
|
+
"stderr": execution["stderr"],
|
|
369
|
+
"combined": execution["stdout"] + "\n" + execution["stderr"],
|
|
370
|
+
}[probe.output_stream]
|
|
371
|
+
output_matches = probe.output_regex is None
|
|
372
|
+
output_match_error: str | None = None
|
|
373
|
+
if probe.output_regex is not None:
|
|
374
|
+
remaining = deadline - time.monotonic()
|
|
375
|
+
if remaining <= 0:
|
|
376
|
+
output_matches = False
|
|
377
|
+
output_match_error = "probe timeout expired before output matching"
|
|
378
|
+
else:
|
|
379
|
+
try:
|
|
380
|
+
output_matches = (
|
|
381
|
+
bounded_regex.search(
|
|
382
|
+
probe.output_regex,
|
|
383
|
+
stream,
|
|
384
|
+
bounded_regex.MULTILINE,
|
|
385
|
+
timeout=min(remaining, OUTPUT_REGEX_TIMEOUT_SECONDS),
|
|
386
|
+
concurrent=True,
|
|
387
|
+
)
|
|
388
|
+
is not None
|
|
389
|
+
)
|
|
390
|
+
except TimeoutError:
|
|
391
|
+
output_matches = False
|
|
392
|
+
output_match_error = "outputRegex exceeded its bounded match timeout"
|
|
393
|
+
satisfied = execution["status"] == "passed" and output_matches
|
|
394
|
+
return {
|
|
395
|
+
**execution,
|
|
396
|
+
"status": "satisfied" if satisfied else "missing",
|
|
397
|
+
"description": requirement.description,
|
|
398
|
+
"outputStream": probe.output_stream,
|
|
399
|
+
"outputRegex": probe.output_regex,
|
|
400
|
+
"outputMatched": output_matches,
|
|
401
|
+
"outputMatchError": output_match_error,
|
|
402
|
+
"remediation": requirement.remediation,
|
|
403
|
+
"setupAction": requirement.setup_action,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def doctor_environment(
|
|
408
|
+
root: Path, project: Project, *, profile: str | None = None
|
|
409
|
+
) -> dict[str, Any]:
|
|
410
|
+
environment, selected, identifiers = _profile_environment(project, profile)
|
|
411
|
+
if environment is None:
|
|
412
|
+
return {
|
|
413
|
+
"status": "not-declared",
|
|
414
|
+
"profile": None,
|
|
415
|
+
"requirements": [],
|
|
416
|
+
}
|
|
417
|
+
path_entries = environment_path_entries(project, profile=selected)
|
|
418
|
+
command_bindings = environment_command_bindings(project, profile=selected)
|
|
419
|
+
results = [
|
|
420
|
+
_probe_requirement(
|
|
421
|
+
root,
|
|
422
|
+
environment.requirements[identifier],
|
|
423
|
+
path_entries=path_entries,
|
|
424
|
+
command_bindings=command_bindings,
|
|
425
|
+
)
|
|
426
|
+
for identifier in identifiers
|
|
427
|
+
]
|
|
428
|
+
return {
|
|
429
|
+
"status": (
|
|
430
|
+
"passed"
|
|
431
|
+
if all(result["status"] == "satisfied" for result in results)
|
|
432
|
+
else "failed"
|
|
433
|
+
),
|
|
434
|
+
"profile": selected,
|
|
435
|
+
"foregroundOnly": environment.foreground_only,
|
|
436
|
+
"requirements": results,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def require_environment_profile(
|
|
441
|
+
root: Path, project: Project, *, profile: str
|
|
442
|
+
) -> dict[str, Any]:
|
|
443
|
+
if project.environment is None:
|
|
444
|
+
return {
|
|
445
|
+
"status": "not-declared",
|
|
446
|
+
"profile": None,
|
|
447
|
+
"requirements": [],
|
|
448
|
+
}
|
|
449
|
+
report = doctor_environment(root, project, profile=profile)
|
|
450
|
+
if report["status"] in {"not-declared", "passed"}:
|
|
451
|
+
return report
|
|
452
|
+
missing = [
|
|
453
|
+
f"{requirement['id']}: {requirement['remediation']}"
|
|
454
|
+
for requirement in report["requirements"]
|
|
455
|
+
if requirement["status"] != "satisfied"
|
|
456
|
+
]
|
|
457
|
+
raise ContractError(
|
|
458
|
+
f"environment profile {profile} is not ready: " + "; ".join(missing)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _action_order(
|
|
463
|
+
environment: ProjectEnvironment, action_ids: set[str]
|
|
464
|
+
) -> list[SetupAction]:
|
|
465
|
+
ordered: list[SetupAction] = []
|
|
466
|
+
visited: set[str] = set()
|
|
467
|
+
|
|
468
|
+
def add(identifier: str) -> None:
|
|
469
|
+
if identifier in visited:
|
|
470
|
+
return
|
|
471
|
+
action = environment.setup_actions[identifier]
|
|
472
|
+
for dependency in action.requires:
|
|
473
|
+
add(dependency)
|
|
474
|
+
visited.add(identifier)
|
|
475
|
+
ordered.append(action)
|
|
476
|
+
|
|
477
|
+
for identifier in sorted(action_ids):
|
|
478
|
+
add(identifier)
|
|
479
|
+
return ordered
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def setup_environment(
|
|
483
|
+
root: Path,
|
|
484
|
+
project: Project,
|
|
485
|
+
*,
|
|
486
|
+
profile: str | None,
|
|
487
|
+
apply: bool,
|
|
488
|
+
allowed_mutations: set[str],
|
|
489
|
+
) -> dict[str, Any]:
|
|
490
|
+
environment, selected, _ = _profile_environment(project, profile)
|
|
491
|
+
if environment is None:
|
|
492
|
+
raise ContractError(
|
|
493
|
+
"setup requires a project environment contract"
|
|
494
|
+
)
|
|
495
|
+
if allowed_mutations and not apply:
|
|
496
|
+
raise ContractError("--allow is valid only together with --apply")
|
|
497
|
+
unknown_mutations = sorted(allowed_mutations - MUTATION_SCOPES)
|
|
498
|
+
if unknown_mutations:
|
|
499
|
+
raise ContractError(
|
|
500
|
+
"unsupported allowed mutation scopes: " + ", ".join(unknown_mutations)
|
|
501
|
+
)
|
|
502
|
+
initial = doctor_environment(root, project, profile=selected)
|
|
503
|
+
missing = [
|
|
504
|
+
result for result in initial["requirements"] if result["status"] != "satisfied"
|
|
505
|
+
]
|
|
506
|
+
if not missing:
|
|
507
|
+
return {
|
|
508
|
+
"status": "passed",
|
|
509
|
+
"mode": "apply" if apply else "plan",
|
|
510
|
+
"profile": selected,
|
|
511
|
+
"initial": initial,
|
|
512
|
+
"actions": [],
|
|
513
|
+
"requiredApprovals": [],
|
|
514
|
+
"blocked": [],
|
|
515
|
+
"final": initial,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
blocked = [
|
|
519
|
+
f"{result['id']}: {result['remediation']}"
|
|
520
|
+
for result in missing
|
|
521
|
+
if result["setupAction"] is None
|
|
522
|
+
]
|
|
523
|
+
action_ids = {
|
|
524
|
+
result["setupAction"]
|
|
525
|
+
for result in missing
|
|
526
|
+
if result["setupAction"] is not None
|
|
527
|
+
}
|
|
528
|
+
actions = _action_order(environment, action_ids)
|
|
529
|
+
planned_tool_ids = {
|
|
530
|
+
action.tool
|
|
531
|
+
for action in actions
|
|
532
|
+
if action.kind == "managed-tool" and action.tool is not None
|
|
533
|
+
}
|
|
534
|
+
provided_commands: dict[str, str] = {}
|
|
535
|
+
preflight_issues: list[str] = []
|
|
536
|
+
try:
|
|
537
|
+
installed_paths = managed_path_entries(
|
|
538
|
+
environment.managed_tools[identifier]
|
|
539
|
+
for identifier in sorted(planned_tool_ids)
|
|
540
|
+
)
|
|
541
|
+
except ContractError as error:
|
|
542
|
+
installed_paths = ()
|
|
543
|
+
preflight_issues.append(str(error))
|
|
544
|
+
try:
|
|
545
|
+
installed_bindings = managed_command_bindings(
|
|
546
|
+
environment.managed_tools[identifier]
|
|
547
|
+
for identifier in sorted(planned_tool_ids)
|
|
548
|
+
)
|
|
549
|
+
except ContractError as error:
|
|
550
|
+
installed_bindings = {}
|
|
551
|
+
preflight_issues.append(str(error))
|
|
552
|
+
for action in actions:
|
|
553
|
+
try:
|
|
554
|
+
if action.kind == "managed-tool":
|
|
555
|
+
assert action.tool is not None
|
|
556
|
+
tool = environment.managed_tools[action.tool]
|
|
557
|
+
artifact = selected_artifact(tool)
|
|
558
|
+
issue = None
|
|
559
|
+
install_issue = managed_tool_preflight(tool)
|
|
560
|
+
if install_issue is not None:
|
|
561
|
+
issue = f"setup action {action.identifier}: {install_issue}"
|
|
562
|
+
for command_name in artifact.commands:
|
|
563
|
+
previous = provided_commands.get(command_name)
|
|
564
|
+
if previous is not None and previous != action.identifier:
|
|
565
|
+
preflight_issues.append(
|
|
566
|
+
"setup actions provide the same managed command "
|
|
567
|
+
f"{command_name}: {previous}, {action.identifier}"
|
|
568
|
+
)
|
|
569
|
+
provided_commands[command_name] = action.identifier
|
|
570
|
+
else:
|
|
571
|
+
dependencies = _action_dependencies(environment, action.identifier)
|
|
572
|
+
planned_commands = {
|
|
573
|
+
command_name
|
|
574
|
+
for dependency in dependencies
|
|
575
|
+
if environment.setup_actions[dependency].kind == "managed-tool"
|
|
576
|
+
for command_name in selected_artifact(
|
|
577
|
+
environment.managed_tools[
|
|
578
|
+
environment.setup_actions[dependency].tool # type: ignore[index]
|
|
579
|
+
]
|
|
580
|
+
).commands
|
|
581
|
+
}
|
|
582
|
+
issue = _command_preflight(
|
|
583
|
+
root,
|
|
584
|
+
identifier=action.identifier,
|
|
585
|
+
run=action.run,
|
|
586
|
+
working_directory=action.working_directory,
|
|
587
|
+
path_entries=installed_paths,
|
|
588
|
+
command_bindings=installed_bindings,
|
|
589
|
+
planned_commands=planned_commands,
|
|
590
|
+
)
|
|
591
|
+
except Exception as error:
|
|
592
|
+
issue = f"setup action {action.identifier}: {error}"
|
|
593
|
+
if issue is not None:
|
|
594
|
+
preflight_issues.append(issue)
|
|
595
|
+
blocked.extend(preflight_issues)
|
|
596
|
+
required_approvals = sorted(
|
|
597
|
+
{scope for action in actions for scope in action.mutations}
|
|
598
|
+
)
|
|
599
|
+
unapproved = sorted(set(required_approvals) - allowed_mutations) if apply else []
|
|
600
|
+
if unapproved:
|
|
601
|
+
blocked.append("unapproved mutation scopes: " + ", ".join(unapproved))
|
|
602
|
+
planned_actions = [
|
|
603
|
+
{
|
|
604
|
+
"id": action.identifier,
|
|
605
|
+
"kind": action.kind,
|
|
606
|
+
"status": "planned",
|
|
607
|
+
"command": (
|
|
608
|
+
list(action.run)
|
|
609
|
+
if action.kind == "command"
|
|
610
|
+
else [
|
|
611
|
+
"managed-tool",
|
|
612
|
+
action.tool,
|
|
613
|
+
environment.managed_tools[action.tool].version, # type: ignore[index]
|
|
614
|
+
]
|
|
615
|
+
),
|
|
616
|
+
"workingDirectory": action.working_directory,
|
|
617
|
+
"mutations": list(action.mutations),
|
|
618
|
+
"requires": list(action.requires),
|
|
619
|
+
}
|
|
620
|
+
for action in actions
|
|
621
|
+
]
|
|
622
|
+
if not apply:
|
|
623
|
+
return {
|
|
624
|
+
"status": "blocked" if blocked else "planned",
|
|
625
|
+
"mode": "plan",
|
|
626
|
+
"profile": selected,
|
|
627
|
+
"initial": initial,
|
|
628
|
+
"actions": planned_actions,
|
|
629
|
+
"requiredApprovals": required_approvals,
|
|
630
|
+
"blocked": blocked,
|
|
631
|
+
"final": None,
|
|
632
|
+
}
|
|
633
|
+
if blocked:
|
|
634
|
+
return {
|
|
635
|
+
"status": "blocked",
|
|
636
|
+
"mode": "apply",
|
|
637
|
+
"profile": selected,
|
|
638
|
+
"initial": initial,
|
|
639
|
+
"actions": planned_actions,
|
|
640
|
+
"requiredApprovals": required_approvals,
|
|
641
|
+
"blocked": blocked,
|
|
642
|
+
"final": None,
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
executed: list[dict[str, Any]] = []
|
|
646
|
+
for action in actions:
|
|
647
|
+
try:
|
|
648
|
+
if action.kind == "managed-tool":
|
|
649
|
+
assert action.tool is not None
|
|
650
|
+
tool = environment.managed_tools[action.tool]
|
|
651
|
+
artifact = selected_artifact(tool)
|
|
652
|
+
started = _timestamp()
|
|
653
|
+
monotonic_start = time.monotonic()
|
|
654
|
+
logical_command = (
|
|
655
|
+
"managed-tool",
|
|
656
|
+
tool.identifier,
|
|
657
|
+
tool.version,
|
|
658
|
+
artifact.platform,
|
|
659
|
+
)
|
|
660
|
+
install_managed_tool(
|
|
661
|
+
tool,
|
|
662
|
+
timeout_seconds=action.timeout_seconds,
|
|
663
|
+
)
|
|
664
|
+
commands = installed_command_bindings(tool)
|
|
665
|
+
result = {
|
|
666
|
+
"id": action.identifier,
|
|
667
|
+
"kind": action.kind,
|
|
668
|
+
"status": "passed",
|
|
669
|
+
"exitCode": 0,
|
|
670
|
+
"startedAt": started,
|
|
671
|
+
"durationMs": round(
|
|
672
|
+
(time.monotonic() - monotonic_start) * 1000
|
|
673
|
+
),
|
|
674
|
+
"workingDirectory": ".",
|
|
675
|
+
"command": list(logical_command),
|
|
676
|
+
"commandSha256": hashlib.sha256(
|
|
677
|
+
json.dumps(
|
|
678
|
+
logical_command,
|
|
679
|
+
ensure_ascii=False,
|
|
680
|
+
separators=(",", ":"),
|
|
681
|
+
).encode("utf-8")
|
|
682
|
+
).hexdigest(),
|
|
683
|
+
"installedCommands": {
|
|
684
|
+
name: {
|
|
685
|
+
"application": str(binding.application),
|
|
686
|
+
"prefixArguments": list(binding.prefix_arguments),
|
|
687
|
+
}
|
|
688
|
+
for name, binding in commands.items()
|
|
689
|
+
},
|
|
690
|
+
}
|
|
691
|
+
else:
|
|
692
|
+
result = execute_command(
|
|
693
|
+
root,
|
|
694
|
+
identifier=action.identifier,
|
|
695
|
+
run=action.run,
|
|
696
|
+
timeout_seconds=action.timeout_seconds,
|
|
697
|
+
working_directory=action.working_directory,
|
|
698
|
+
path_entries=managed_path_entries(
|
|
699
|
+
environment.managed_tools[identifier]
|
|
700
|
+
for identifier in sorted(planned_tool_ids)
|
|
701
|
+
),
|
|
702
|
+
command_bindings=managed_command_bindings(
|
|
703
|
+
environment.managed_tools[identifier]
|
|
704
|
+
for identifier in sorted(planned_tool_ids)
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
except Exception as error:
|
|
708
|
+
result = {
|
|
709
|
+
"id": action.identifier,
|
|
710
|
+
"kind": action.kind,
|
|
711
|
+
"status": "failed-to-start",
|
|
712
|
+
"command": (
|
|
713
|
+
list(action.run)
|
|
714
|
+
if action.kind == "command"
|
|
715
|
+
else ["managed-tool", action.tool]
|
|
716
|
+
),
|
|
717
|
+
"workingDirectory": action.working_directory,
|
|
718
|
+
"error": str(error),
|
|
719
|
+
"errorType": type(error).__name__,
|
|
720
|
+
}
|
|
721
|
+
result["mutations"] = list(action.mutations)
|
|
722
|
+
result["requires"] = list(action.requires)
|
|
723
|
+
executed.append(result)
|
|
724
|
+
if result["status"] != "passed":
|
|
725
|
+
return {
|
|
726
|
+
"status": "failed",
|
|
727
|
+
"mode": "apply",
|
|
728
|
+
"profile": selected,
|
|
729
|
+
"initial": initial,
|
|
730
|
+
"actions": executed,
|
|
731
|
+
"requiredApprovals": required_approvals,
|
|
732
|
+
"blocked": [],
|
|
733
|
+
"final": None,
|
|
734
|
+
}
|
|
735
|
+
try:
|
|
736
|
+
final = doctor_environment(root, project, profile=selected)
|
|
737
|
+
except Exception as error:
|
|
738
|
+
final = {
|
|
739
|
+
"status": "failed-to-start",
|
|
740
|
+
"error": str(error),
|
|
741
|
+
"errorType": type(error).__name__,
|
|
742
|
+
"requirements": [],
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
"status": "passed" if final["status"] == "passed" else "failed",
|
|
746
|
+
"mode": "apply",
|
|
747
|
+
"profile": selected,
|
|
748
|
+
"initial": initial,
|
|
749
|
+
"actions": executed,
|
|
750
|
+
"requiredApprovals": required_approvals,
|
|
751
|
+
"blocked": [],
|
|
752
|
+
"final": final,
|
|
753
|
+
}
|