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,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .contracts import Check, ContractError, Project
|
|
13
|
+
from .environment import (
|
|
14
|
+
environment_command_bindings,
|
|
15
|
+
environment_path_entries,
|
|
16
|
+
execute_command,
|
|
17
|
+
)
|
|
18
|
+
from .tooling import ManagedCommandBinding
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _timestamp() -> str:
|
|
22
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _git(root: Path, arguments: list[str]) -> subprocess.CompletedProcess[bytes]:
|
|
26
|
+
return subprocess.run(
|
|
27
|
+
["git", *arguments],
|
|
28
|
+
cwd=root,
|
|
29
|
+
check=False,
|
|
30
|
+
stdin=subprocess.DEVNULL,
|
|
31
|
+
stdout=subprocess.PIPE,
|
|
32
|
+
stderr=subprocess.DEVNULL,
|
|
33
|
+
timeout=10,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _source_state(root: Path) -> dict[str, Any]:
|
|
38
|
+
status_result = _git(
|
|
39
|
+
root,
|
|
40
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
41
|
+
)
|
|
42
|
+
if status_result.returncode != 0:
|
|
43
|
+
return {"checkpoint": None, "dirty": None, "fingerprint": None}
|
|
44
|
+
|
|
45
|
+
head_result = _git(root, ["rev-parse", "HEAD"])
|
|
46
|
+
checkpoint = (
|
|
47
|
+
head_result.stdout.decode("ascii").strip()
|
|
48
|
+
if head_result.returncode == 0
|
|
49
|
+
else None
|
|
50
|
+
)
|
|
51
|
+
diff = b""
|
|
52
|
+
if checkpoint is not None:
|
|
53
|
+
diff_result = _git(
|
|
54
|
+
root,
|
|
55
|
+
["diff", "--binary", "--no-ext-diff", "--no-textconv", "HEAD", "--"],
|
|
56
|
+
)
|
|
57
|
+
if diff_result.returncode != 0:
|
|
58
|
+
return {"checkpoint": checkpoint, "dirty": True, "fingerprint": None}
|
|
59
|
+
diff = diff_result.stdout
|
|
60
|
+
|
|
61
|
+
untracked_result = _git(
|
|
62
|
+
root,
|
|
63
|
+
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
64
|
+
)
|
|
65
|
+
if untracked_result.returncode != 0:
|
|
66
|
+
return {
|
|
67
|
+
"checkpoint": checkpoint,
|
|
68
|
+
"dirty": bool(status_result.stdout),
|
|
69
|
+
"fingerprint": None,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
digest = hashlib.sha256()
|
|
73
|
+
digest.update(b"checkpoint\0")
|
|
74
|
+
digest.update((checkpoint or "").encode("ascii"))
|
|
75
|
+
digest.update(b"\0status\0")
|
|
76
|
+
digest.update(status_result.stdout)
|
|
77
|
+
digest.update(b"\0diff\0")
|
|
78
|
+
digest.update(diff)
|
|
79
|
+
for encoded_path in sorted(
|
|
80
|
+
path for path in untracked_result.stdout.split(b"\0") if path
|
|
81
|
+
):
|
|
82
|
+
relative = os.fsdecode(encoded_path)
|
|
83
|
+
path = root / relative
|
|
84
|
+
digest.update(b"\0untracked\0")
|
|
85
|
+
digest.update(encoded_path)
|
|
86
|
+
try:
|
|
87
|
+
mode = path.lstat().st_mode
|
|
88
|
+
if stat.S_ISLNK(mode):
|
|
89
|
+
digest.update(b"\0symlink\0")
|
|
90
|
+
digest.update(os.fsencode(os.readlink(path)))
|
|
91
|
+
elif stat.S_ISREG(mode):
|
|
92
|
+
digest.update(b"\0file\0")
|
|
93
|
+
with path.open("rb") as source:
|
|
94
|
+
while chunk := source.read(1024 * 1024):
|
|
95
|
+
digest.update(chunk)
|
|
96
|
+
else:
|
|
97
|
+
digest.update(b"\0other\0")
|
|
98
|
+
digest.update(str(mode).encode("ascii"))
|
|
99
|
+
except OSError as error:
|
|
100
|
+
digest.update(b"\0unreadable\0")
|
|
101
|
+
digest.update(str(error).encode("utf-8", errors="replace"))
|
|
102
|
+
return {
|
|
103
|
+
"checkpoint": checkpoint,
|
|
104
|
+
"dirty": bool(status_result.stdout),
|
|
105
|
+
"fingerprint": f"sha256:{digest.hexdigest()}",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def source_state(root: Path) -> dict[str, Any]:
|
|
110
|
+
"""Return the checkpoint state used to bind lifecycle evidence."""
|
|
111
|
+
return _source_state(root)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _run_check(
|
|
115
|
+
root: Path,
|
|
116
|
+
check: Check,
|
|
117
|
+
*,
|
|
118
|
+
path_entries: tuple[Path, ...] = (),
|
|
119
|
+
command_bindings: dict[str, ManagedCommandBinding] | None = None,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
print(f"[{check.identifier}] {' '.join(check.run)}", file=sys.stderr)
|
|
122
|
+
execution = execute_command(
|
|
123
|
+
root,
|
|
124
|
+
identifier=check.identifier,
|
|
125
|
+
run=check.run,
|
|
126
|
+
timeout_seconds=check.timeout_seconds,
|
|
127
|
+
working_directory=check.working_directory,
|
|
128
|
+
path_entries=path_entries,
|
|
129
|
+
command_bindings=command_bindings,
|
|
130
|
+
stream_output=True,
|
|
131
|
+
)
|
|
132
|
+
allowed = {
|
|
133
|
+
"id",
|
|
134
|
+
"status",
|
|
135
|
+
"exitCode",
|
|
136
|
+
"startedAt",
|
|
137
|
+
"durationMs",
|
|
138
|
+
"workingDirectory",
|
|
139
|
+
"command",
|
|
140
|
+
"commandSha256",
|
|
141
|
+
"error",
|
|
142
|
+
"pathEntries",
|
|
143
|
+
}
|
|
144
|
+
return {key: value for key, value in execution.items() if key in allowed}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_profile(root: Path, project: Project, profile: str) -> dict[str, Any]:
|
|
148
|
+
checks = project.profiles.get(profile)
|
|
149
|
+
if checks is None:
|
|
150
|
+
available = ", ".join(sorted(project.profiles))
|
|
151
|
+
raise ContractError(
|
|
152
|
+
f"unknown profile {profile}; available profiles: {available}"
|
|
153
|
+
)
|
|
154
|
+
started = _timestamp()
|
|
155
|
+
source_before = _source_state(root)
|
|
156
|
+
path_entries = environment_path_entries(project, profile=profile)
|
|
157
|
+
command_bindings = environment_command_bindings(project, profile=profile)
|
|
158
|
+
results = [
|
|
159
|
+
_run_check(
|
|
160
|
+
root,
|
|
161
|
+
check,
|
|
162
|
+
path_entries=path_entries,
|
|
163
|
+
command_bindings=command_bindings,
|
|
164
|
+
)
|
|
165
|
+
for check in checks
|
|
166
|
+
]
|
|
167
|
+
source_after = _source_state(root)
|
|
168
|
+
source_changed = (
|
|
169
|
+
source_before["fingerprint"] is not None
|
|
170
|
+
and source_after["fingerprint"] is not None
|
|
171
|
+
and source_before["fingerprint"] != source_after["fingerprint"]
|
|
172
|
+
)
|
|
173
|
+
status = (
|
|
174
|
+
"passed"
|
|
175
|
+
if all(item["status"] == "passed" for item in results) and not source_changed
|
|
176
|
+
else "failed"
|
|
177
|
+
)
|
|
178
|
+
return {
|
|
179
|
+
"schemaVersion": 1,
|
|
180
|
+
"project": project.identifier,
|
|
181
|
+
"profile": profile,
|
|
182
|
+
"checkpoint": source_before["checkpoint"],
|
|
183
|
+
"workingTreeDirty": source_before["dirty"],
|
|
184
|
+
"workspaceFingerprint": source_before["fingerprint"],
|
|
185
|
+
"completedWorkspaceFingerprint": source_after["fingerprint"],
|
|
186
|
+
"sourceChangedDuringVerification": source_changed,
|
|
187
|
+
"startedAt": started,
|
|
188
|
+
"completedAt": _timestamp(),
|
|
189
|
+
"status": status,
|
|
190
|
+
"checks": results,
|
|
191
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from importlib import metadata, resources
|
|
5
|
+
|
|
6
|
+
from .contracts import ContractError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PIN_PATTERN = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s]+)$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def runtime_dependency_pins() -> dict[str, str]:
|
|
13
|
+
text = (
|
|
14
|
+
resources.files("engineering_process")
|
|
15
|
+
.joinpath("requirements-runtime.txt")
|
|
16
|
+
.read_text(encoding="utf-8")
|
|
17
|
+
)
|
|
18
|
+
pins: dict[str, str] = {}
|
|
19
|
+
for line_number, raw_line in enumerate(text.splitlines(), 1):
|
|
20
|
+
line = raw_line.strip()
|
|
21
|
+
if not line or line.startswith("#"):
|
|
22
|
+
continue
|
|
23
|
+
match = PIN_PATTERN.fullmatch(line)
|
|
24
|
+
if match is None:
|
|
25
|
+
raise ContractError(
|
|
26
|
+
"engineering_process/requirements-runtime.txt:"
|
|
27
|
+
f"{line_number}: runtime dependencies must use exact name==version pins"
|
|
28
|
+
)
|
|
29
|
+
name, version = match.groups()
|
|
30
|
+
normalized = re.sub(r"[-_.]+", "-", name).lower()
|
|
31
|
+
if normalized in pins:
|
|
32
|
+
raise ContractError(
|
|
33
|
+
"engineering_process/requirements-runtime.txt: duplicate dependency "
|
|
34
|
+
f"{normalized}"
|
|
35
|
+
)
|
|
36
|
+
pins[normalized] = version
|
|
37
|
+
if not pins:
|
|
38
|
+
raise ContractError("runtime dependency lock must not be empty")
|
|
39
|
+
return pins
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assert_runtime_dependencies() -> None:
|
|
43
|
+
mismatches: list[str] = []
|
|
44
|
+
for name, expected in runtime_dependency_pins().items():
|
|
45
|
+
try:
|
|
46
|
+
actual = metadata.version(name)
|
|
47
|
+
except metadata.PackageNotFoundError:
|
|
48
|
+
mismatches.append(f"{name} is missing; expected {expected}")
|
|
49
|
+
continue
|
|
50
|
+
if actual != expected:
|
|
51
|
+
mismatches.append(f"{name} is {actual}; expected {expected}")
|
|
52
|
+
if mismatches:
|
|
53
|
+
raise ContractError(
|
|
54
|
+
"engineering-process runtime dependency mismatch: " + "; ".join(mismatches)
|
|
55
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .contracts import ContractError, SKILL_PATTERN
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
FRONTMATTER = re.compile(
|
|
11
|
+
r"\A---\n(?P<header>.*?)\n---\n(?P<body>.*)\Z",
|
|
12
|
+
re.DOTALL,
|
|
13
|
+
)
|
|
14
|
+
FORBIDDEN_CORE_TERMS = (
|
|
15
|
+
"apply_patch",
|
|
16
|
+
"claude",
|
|
17
|
+
"codex",
|
|
18
|
+
"cursor",
|
|
19
|
+
"gemini",
|
|
20
|
+
"gpt-",
|
|
21
|
+
"reasoning_effort",
|
|
22
|
+
"spawn_agent",
|
|
23
|
+
"subagent",
|
|
24
|
+
)
|
|
25
|
+
MARKER_NAME = ".engineering-process.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def skill_directories(root: Path) -> list[Path]:
|
|
29
|
+
if not root.is_dir():
|
|
30
|
+
raise ContractError(f"{root}: skills root does not exist")
|
|
31
|
+
return sorted(
|
|
32
|
+
path for path in root.iterdir() if path.is_dir() and (path / "SKILL.md").is_file()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_skills(root: Path, selected: tuple[str, ...] | None = None) -> list[str]:
|
|
37
|
+
directories = {path.name: path for path in skill_directories(root)}
|
|
38
|
+
names = tuple(sorted(directories)) if selected is None else selected
|
|
39
|
+
issues: list[str] = []
|
|
40
|
+
missing = sorted(set(names) - set(directories))
|
|
41
|
+
if missing:
|
|
42
|
+
issues.append(f"{root}: missing skills: {', '.join(missing)}")
|
|
43
|
+
|
|
44
|
+
for name in names:
|
|
45
|
+
directory = directories.get(name)
|
|
46
|
+
if directory is None:
|
|
47
|
+
continue
|
|
48
|
+
if SKILL_PATTERN.fullmatch(name) is None or len(name) > 64:
|
|
49
|
+
issues.append(f"{directory}: invalid skill directory name")
|
|
50
|
+
path = directory / "SKILL.md"
|
|
51
|
+
try:
|
|
52
|
+
text = path.read_text(encoding="utf-8")
|
|
53
|
+
except (OSError, UnicodeError) as error:
|
|
54
|
+
issues.append(f"{path}: cannot read UTF-8 content: {error}")
|
|
55
|
+
continue
|
|
56
|
+
if text.startswith("\ufeff"):
|
|
57
|
+
issues.append(f"{path}: UTF-8 BOM is not allowed")
|
|
58
|
+
if "\r" in text:
|
|
59
|
+
issues.append(f"{path}: use LF line endings")
|
|
60
|
+
if "TODO" in text or "[TODO" in text:
|
|
61
|
+
issues.append(f"{path}: unresolved template marker")
|
|
62
|
+
if len(text.splitlines()) > 500:
|
|
63
|
+
issues.append(f"{path}: exceeds the 500-line portability limit")
|
|
64
|
+
|
|
65
|
+
match = FRONTMATTER.match(text)
|
|
66
|
+
if match is None:
|
|
67
|
+
issues.append(f"{path}: invalid YAML frontmatter boundary")
|
|
68
|
+
continue
|
|
69
|
+
header: dict[str, str] = {}
|
|
70
|
+
for line_number, line in enumerate(match.group("header").splitlines(), 2):
|
|
71
|
+
if not line or line.startswith((" ", "\t", "#")) or ":" not in line:
|
|
72
|
+
issues.append(f"{path}:{line_number}: unsupported frontmatter syntax")
|
|
73
|
+
continue
|
|
74
|
+
key, value = line.split(":", 1)
|
|
75
|
+
if key in header:
|
|
76
|
+
issues.append(f"{path}:{line_number}: duplicate frontmatter key {key}")
|
|
77
|
+
header[key] = value.strip()
|
|
78
|
+
extra = sorted(set(header) - {"name", "description"})
|
|
79
|
+
if extra:
|
|
80
|
+
issues.append(
|
|
81
|
+
f"{path}: core skills allow only name and description; found {extra}"
|
|
82
|
+
)
|
|
83
|
+
if header.get("name") != name:
|
|
84
|
+
issues.append(f"{path}: frontmatter name must match {name}")
|
|
85
|
+
description = header.get("description", "")
|
|
86
|
+
if not description or len(description) > 1024:
|
|
87
|
+
issues.append(f"{path}: description must contain 1 to 1024 characters")
|
|
88
|
+
if not match.group("body").lstrip().startswith("# "):
|
|
89
|
+
issues.append(f"{path}: body must start with an H1")
|
|
90
|
+
|
|
91
|
+
lowered = text.lower()
|
|
92
|
+
for term in FORBIDDEN_CORE_TERMS:
|
|
93
|
+
if term in lowered:
|
|
94
|
+
issues.append(f"{path}: agent-specific core term is forbidden: {term}")
|
|
95
|
+
for line_number, line in enumerate(text.splitlines(), 1):
|
|
96
|
+
if re.search(r"(?:^|[\s(])/(?:home|users|workspace|tmp)/", line.lower()):
|
|
97
|
+
issues.append(f"{path}:{line_number}: absolute local path is forbidden")
|
|
98
|
+
return issues
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def skill_digest(root: Path, selected: tuple[str, ...] | None = None) -> str:
|
|
102
|
+
issues = validate_skills(root, selected)
|
|
103
|
+
if issues:
|
|
104
|
+
raise ContractError("\n".join(issues))
|
|
105
|
+
directories = {path.name: path for path in skill_directories(root)}
|
|
106
|
+
names = tuple(sorted(directories)) if selected is None else selected
|
|
107
|
+
digest = hashlib.sha256()
|
|
108
|
+
for name in names:
|
|
109
|
+
directory = directories[name]
|
|
110
|
+
for path in sorted(item for item in directory.rglob("*") if item.is_file()):
|
|
111
|
+
if path.name == MARKER_NAME:
|
|
112
|
+
continue
|
|
113
|
+
relative = path.relative_to(root).as_posix()
|
|
114
|
+
digest.update(relative.encode("utf-8"))
|
|
115
|
+
digest.update(b"\0")
|
|
116
|
+
digest.update(path.read_bytes())
|
|
117
|
+
digest.update(b"\0")
|
|
118
|
+
return f"sha256:{digest.hexdigest()}"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Portable foreground-task supervision.
|
|
2
|
+
|
|
3
|
+
The public execution contract is deliberately platform-neutral. Kernel-specific
|
|
4
|
+
process containment lives in the selected backend and must not leak into consumer
|
|
5
|
+
manifests, reports, or lifecycle code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import Mapping, Protocol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CleanupOutcome:
|
|
19
|
+
"""Result of a bounded process-tree cleanup operation."""
|
|
20
|
+
|
|
21
|
+
bounded: bool
|
|
22
|
+
descendants_found: bool = False
|
|
23
|
+
error: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProcessSupervisor(Protocol):
|
|
27
|
+
"""OS backend for one finite, non-interactive foreground task."""
|
|
28
|
+
|
|
29
|
+
def resolve_application(
|
|
30
|
+
self,
|
|
31
|
+
command: str,
|
|
32
|
+
*,
|
|
33
|
+
working_directory: Path,
|
|
34
|
+
environment: Mapping[str, str],
|
|
35
|
+
) -> Path: ...
|
|
36
|
+
|
|
37
|
+
def spawn(
|
|
38
|
+
self,
|
|
39
|
+
command: tuple[str, ...],
|
|
40
|
+
*,
|
|
41
|
+
working_directory: Path,
|
|
42
|
+
environment: Mapping[str, str],
|
|
43
|
+
) -> subprocess.Popen[bytes]: ...
|
|
44
|
+
|
|
45
|
+
def terminate(
|
|
46
|
+
self,
|
|
47
|
+
process: subprocess.Popen[bytes],
|
|
48
|
+
*,
|
|
49
|
+
grace_seconds: float,
|
|
50
|
+
) -> CleanupOutcome: ...
|
|
51
|
+
|
|
52
|
+
def finalize(
|
|
53
|
+
self,
|
|
54
|
+
process: subprocess.Popen[bytes],
|
|
55
|
+
*,
|
|
56
|
+
grace_seconds: float,
|
|
57
|
+
) -> CleanupOutcome: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def process_supervisor(*, platform_name: str | None = None) -> ProcessSupervisor:
|
|
61
|
+
"""Select the sole platform adapter used by the execution layer."""
|
|
62
|
+
|
|
63
|
+
selected = os.name if platform_name is None else platform_name
|
|
64
|
+
if selected == "posix":
|
|
65
|
+
from ._supervisor_posix import POSIX_SUPERVISOR
|
|
66
|
+
|
|
67
|
+
return POSIX_SUPERVISOR
|
|
68
|
+
if selected == "nt":
|
|
69
|
+
from ._supervisor_windows import WINDOWS_SUPERVISOR
|
|
70
|
+
|
|
71
|
+
return WINDOWS_SUPERVISOR
|
|
72
|
+
raise OSError(f"unsupported process supervision platform: {selected}")
|