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.
Files changed (69) hide show
  1. engineering_process/__init__.py +3 -0
  2. engineering_process/_download_worker.py +73 -0
  3. engineering_process/_supervisor_posix.py +162 -0
  4. engineering_process/_supervisor_windows.py +148 -0
  5. engineering_process/_windows_job.py +419 -0
  6. engineering_process/bootstrap.py +217 -0
  7. engineering_process/bundles.py +81 -0
  8. engineering_process/cli.py +1012 -0
  9. engineering_process/contracts.py +1274 -0
  10. engineering_process/distribution.py +90 -0
  11. engineering_process/environment.py +753 -0
  12. engineering_process/helper_launch.py +37 -0
  13. engineering_process/lifecycle.py +1024 -0
  14. engineering_process/managed.py +89 -0
  15. engineering_process/markdown.py +78 -0
  16. engineering_process/publication.py +463 -0
  17. engineering_process/requirements-build.txt +1 -0
  18. engineering_process/requirements-dev.txt +10 -0
  19. engineering_process/requirements-runtime.txt +3 -0
  20. engineering_process/runner.py +191 -0
  21. engineering_process/runtime.py +55 -0
  22. engineering_process/skills.py +118 -0
  23. engineering_process/supervision.py +72 -0
  24. engineering_process/syncing.py +398 -0
  25. engineering_process/tooling.py +891 -0
  26. engineering_process-0.1.0.data/data/share/engineering-process/bundles.json +47 -0
  27. engineering_process-0.1.0.data/data/share/engineering-process/examples/change.json +26 -0
  28. engineering_process-0.1.0.data/data/share/engineering-process/examples/plan.json +28 -0
  29. engineering_process-0.1.0.data/data/share/engineering-process/examples/project.json +54 -0
  30. engineering_process-0.1.0.data/data/share/engineering-process/examples/review.json +31 -0
  31. engineering_process-0.1.0.data/data/share/engineering-process/schemas/change.schema.json +88 -0
  32. engineering_process-0.1.0.data/data/share/engineering-process/schemas/lifecycle.schema.json +122 -0
  33. engineering_process-0.1.0.data/data/share/engineering-process/schemas/plan.schema.json +105 -0
  34. engineering_process-0.1.0.data/data/share/engineering-process/schemas/process-lock.schema.json +39 -0
  35. engineering_process-0.1.0.data/data/share/engineering-process/schemas/project.schema.json +515 -0
  36. engineering_process-0.1.0.data/data/share/engineering-process/schemas/review.schema.json +140 -0
  37. engineering_process-0.1.0.data/data/share/engineering-process/schemas/reviewer-attestation.schema.json +30 -0
  38. engineering_process-0.1.0.data/data/share/engineering-process/schemas/verification.schema.json +120 -0
  39. engineering_process-0.1.0.data/data/share/engineering-process/skills/assess-design/SKILL.md +39 -0
  40. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend/SKILL.md +38 -0
  41. engineering_process-0.1.0.data/data/share/engineering-process/skills/build-frontend-foundation/SKILL.md +36 -0
  42. engineering_process-0.1.0.data/data/share/engineering-process/skills/change-api/SKILL.md +36 -0
  43. engineering_process-0.1.0.data/data/share/engineering-process/skills/cross-repo-change/SKILL.md +37 -0
  44. engineering_process-0.1.0.data/data/share/engineering-process/skills/define-change-contract/SKILL.md +40 -0
  45. engineering_process-0.1.0.data/data/share/engineering-process/skills/design-module/SKILL.md +37 -0
  46. engineering_process-0.1.0.data/data/share/engineering-process/skills/evolve-process/SKILL.md +37 -0
  47. engineering_process-0.1.0.data/data/share/engineering-process/skills/finish-change/SKILL.md +34 -0
  48. engineering_process-0.1.0.data/data/share/engineering-process/skills/govern-ui/SKILL.md +40 -0
  49. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-change/SKILL.md +41 -0
  50. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-module/SKILL.md +36 -0
  51. engineering_process-0.1.0.data/data/share/engineering-process/skills/implement-use-case/SKILL.md +36 -0
  52. engineering_process-0.1.0.data/data/share/engineering-process/skills/integrate-mcp/SKILL.md +37 -0
  53. engineering_process-0.1.0.data/data/share/engineering-process/skills/maintain-docs/SKILL.md +35 -0
  54. engineering_process-0.1.0.data/data/share/engineering-process/skills/plan-change/SKILL.md +37 -0
  55. engineering_process-0.1.0.data/data/share/engineering-process/skills/publish-change/SKILL.md +38 -0
  56. engineering_process-0.1.0.data/data/share/engineering-process/skills/review-change/SKILL.md +49 -0
  57. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/SKILL.md +51 -0
  58. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-change/references/execution.md +88 -0
  59. engineering_process-0.1.0.data/data/share/engineering-process/skills/run-project-command/SKILL.md +55 -0
  60. engineering_process-0.1.0.data/data/share/engineering-process/skills/specify-use-case/SKILL.md +37 -0
  61. engineering_process-0.1.0.data/data/share/engineering-process/skills/verify-change/SKILL.md +38 -0
  62. engineering_process-0.1.0.data/data/share/engineering-process/templates/AGENTS.process.md +17 -0
  63. engineering_process-0.1.0.data/data/share/engineering-process/templates/PULL_REQUEST_TEMPLATE.md +27 -0
  64. engineering_process-0.1.0.dist-info/METADATA +387 -0
  65. engineering_process-0.1.0.dist-info/RECORD +69 -0
  66. engineering_process-0.1.0.dist-info/WHEEL +5 -0
  67. engineering_process-0.1.0.dist-info/entry_points.txt +2 -0
  68. engineering_process-0.1.0.dist-info/licenses/LICENSE +21 -0
  69. 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
+ }