feat(devkit): add resumable workspace automation and UI review tooling
Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
This commit is contained in:
Executable
+271
@@ -0,0 +1,271 @@
|
||||
"""Resolve local tools once, without installation, network access or server startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from itertools import islice
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .common import digest
|
||||
from .workspace import Project
|
||||
from .process import require_capture
|
||||
|
||||
MAX_DISCOVERY_CHILDREN = 4096
|
||||
|
||||
|
||||
def resolve_tools(workspace_root: Path, project: Project) -> dict[str, str]:
|
||||
configured = project.config.get("tools", {})
|
||||
if not isinstance(configured, dict) or set(configured) - {"python", "node", "npm"}:
|
||||
raise ValueError("Project tools must configure only python, node and npm")
|
||||
tools = {}
|
||||
python_env = (
|
||||
Path(
|
||||
os.environ.get("GOVOPLAN_VENV_ROOT", str(workspace_root / "govoplan/.venv"))
|
||||
)
|
||||
/ "bin/python"
|
||||
)
|
||||
for name in ("python", "node", "npm"):
|
||||
default = (
|
||||
str(python_env)
|
||||
if name == "python" and python_env.is_file()
|
||||
else sys.executable
|
||||
if name == "python"
|
||||
else name
|
||||
)
|
||||
value = configured.get(name) or os.environ.get(name.upper()) or default
|
||||
if not isinstance(value, str) or not value or "\0" in value:
|
||||
raise ValueError(f"Invalid executable configuration for {name}")
|
||||
executable = shutil.which(value)
|
||||
if executable:
|
||||
# Keep venv executable symlinks: resolving them loses Python's environment.
|
||||
tools[name] = os.path.abspath(executable)
|
||||
else:
|
||||
tools[name] = value
|
||||
return tools
|
||||
|
||||
|
||||
def execution_environment(
|
||||
workspace_root: Path, project: Project, tools: dict[str, str]
|
||||
) -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
if (
|
||||
project.config.get("organization") == "GovOPlaN"
|
||||
and "schema_version" not in project.config
|
||||
):
|
||||
# Managed GovOPlaN checks must not inherit another checkout's scope.
|
||||
env["GOVOPLAN_WORKSPACE_ROOT"] = str(workspace_root.resolve())
|
||||
core = next(
|
||||
repo.path for repo in project.repositories if repo.name == "govoplan-core"
|
||||
)
|
||||
env["GOVOPLAN_CORE_ROOT"] = str(core)
|
||||
env["GOVOPLAN_CORE_SOURCE_ROOT"] = str(core)
|
||||
directories = [
|
||||
str(Path(tools[name]).parent)
|
||||
for name in ("python", "node", "npm")
|
||||
if Path(tools[name]).is_absolute()
|
||||
]
|
||||
core_bins = workspace_root / "govoplan-core/webui/node_modules/.bin"
|
||||
if core_bins.is_dir():
|
||||
directories.insert(0, str(core_bins))
|
||||
env["PATH"] = os.pathsep.join([*directories, env.get("PATH", "")])
|
||||
env.update({name.upper(): value for name, value in tools.items()})
|
||||
sources = [
|
||||
str(repo.path / "src")
|
||||
for repo in project.repositories
|
||||
if (repo.path / "src").is_dir()
|
||||
]
|
||||
if sources:
|
||||
env["PYTHONPATH"] = os.pathsep.join(
|
||||
sources + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])
|
||||
)
|
||||
env["GIT_OPTIONAL_LOCKS"] = "0"
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def tool_version(executable: str, env: dict[str, str]) -> str:
|
||||
try:
|
||||
result = require_capture(
|
||||
[executable, "--version"],
|
||||
timeout=15,
|
||||
max_stdout=4096,
|
||||
max_stderr=4096,
|
||||
env=env,
|
||||
)
|
||||
return (
|
||||
(result.stdout or result.stderr).decode(errors="replace").strip()[:256]
|
||||
if result.returncode == 0
|
||||
else "unavailable"
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError):
|
||||
return "unavailable"
|
||||
|
||||
|
||||
def _file_identity(metadata: os.stat_result) -> tuple[int, ...]:
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
metadata.st_mode,
|
||||
metadata.st_size,
|
||||
metadata.st_ctime_ns,
|
||||
metadata.st_mtime_ns,
|
||||
)
|
||||
|
||||
|
||||
def _environment_file_hash(path: Path, maximum: int) -> str | None:
|
||||
"""Read a stable, bounded regular target without changing executable spelling.
|
||||
|
||||
Venv executables and package directories may legitimately be symlinks. Only
|
||||
the read target is resolved; both names and the open descriptor are verified
|
||||
again afterwards. Missing optional metadata stays absent, but a present
|
||||
malformed or concurrently changing entry cannot certify an environment.
|
||||
"""
|
||||
try:
|
||||
original = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
target = path.resolve(strict=True)
|
||||
target_metadata = target.lstat()
|
||||
descriptor = os.open(
|
||||
target,
|
||||
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
|
||||
)
|
||||
with os.fdopen(descriptor, "rb") as handle:
|
||||
before = os.fstat(handle.fileno())
|
||||
if not stat.S_ISREG(before.st_mode) or before.st_size > maximum:
|
||||
raise ValueError("Environment input must be a bounded regular file")
|
||||
identity = _file_identity(before)
|
||||
if identity != _file_identity(target_metadata):
|
||||
raise ValueError("Environment input changed before fingerprinting")
|
||||
hasher, size = hashlib.sha256(), 0
|
||||
for chunk in iter(
|
||||
lambda: handle.read(min(1024 * 1024, maximum - size + 1)), b""
|
||||
):
|
||||
size += len(chunk)
|
||||
if size > maximum:
|
||||
raise ValueError("Environment input grew beyond its size bound")
|
||||
hasher.update(chunk)
|
||||
if (
|
||||
_file_identity(os.fstat(handle.fileno())) != identity
|
||||
or _file_identity(target.lstat()) != identity
|
||||
or _file_identity(path.lstat()) != _file_identity(original)
|
||||
or path.resolve(strict=True) != target
|
||||
):
|
||||
raise ValueError("Environment input changed during fingerprinting")
|
||||
return hasher.hexdigest()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise ValueError("Environment input cannot be fingerprinted safely") from exc
|
||||
|
||||
|
||||
def _discovery_path_shape(path: Path) -> dict:
|
||||
"""Directory membership, not timestamps changed by ordinary build outputs."""
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
return {"kind": "missing"}
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
try:
|
||||
target_kind = stat.S_IFMT(resolved.lstat().st_mode)
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
target_kind = "missing"
|
||||
if target_kind == stat.S_IFLNK:
|
||||
# Newer pathlib versions can retain a loop with strict=False.
|
||||
raise ValueError("Native source discovery cannot be resolved safely")
|
||||
return {
|
||||
"kind": stat.S_IFMT(metadata.st_mode),
|
||||
"resolved": str(resolved),
|
||||
"target_kind": target_kind,
|
||||
}
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise ValueError("Native source discovery cannot be resolved safely") from exc
|
||||
|
||||
|
||||
def _native_discovery_fingerprint(workspace_root: Path, project: Project) -> str:
|
||||
"""Bind glob discovery and registered ownership omitted by narrow Git scopes."""
|
||||
children = list(islice(workspace_root.iterdir(), MAX_DISCOVERY_CHILDREN + 1))
|
||||
if len(children) > MAX_DISCOVERY_CHILDREN:
|
||||
raise ValueError("Native source discovery exceeds its bounded ownership audit")
|
||||
|
||||
def shape(path):
|
||||
return {
|
||||
name: _discovery_path_shape(path / name if name else path)
|
||||
for name in ("", "src", "webui")
|
||||
}
|
||||
|
||||
return digest(
|
||||
{
|
||||
"version": 1,
|
||||
"siblings": [
|
||||
{"name": child.name, "shape": shape(child)}
|
||||
for child in sorted(children)
|
||||
if child.name.startswith("govoplan")
|
||||
],
|
||||
"registered": [
|
||||
{"name": repo.name, "path": str(repo.path), "shape": shape(repo.path)}
|
||||
for repo in sorted(project.repositories, key=lambda item: item.name)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def environment_fingerprint(
|
||||
workspace_root: Path, project: Project, tools: dict[str, str], env: dict[str, str]
|
||||
) -> str:
|
||||
identity = {
|
||||
"environment": {
|
||||
key: value
|
||||
for key, value in env.items()
|
||||
if key not in {"_", "SHLVL", "PWD", "OLDPWD"}
|
||||
},
|
||||
"tools": {},
|
||||
}
|
||||
if (
|
||||
project.config.get("organization") == "GovOPlaN"
|
||||
and "schema_version" not in project.config
|
||||
):
|
||||
identity["native_source_discovery"] = _native_discovery_fingerprint(
|
||||
workspace_root, project
|
||||
)
|
||||
for name, executable in tools.items():
|
||||
binary_hash = _environment_file_hash(Path(executable), 512 * 1024 * 1024)
|
||||
identity["tools"][name] = {
|
||||
"path": executable,
|
||||
"version": tool_version(executable, env),
|
||||
"sha256": binary_hash,
|
||||
}
|
||||
installed = {}
|
||||
for repo in project.repositories:
|
||||
for suffix in (
|
||||
"node_modules/.package-lock.json",
|
||||
"webui/node_modules/.package-lock.json",
|
||||
".venv/pyvenv.cfg",
|
||||
):
|
||||
path = repo.path / suffix
|
||||
value = _environment_file_hash(path, 32 * 1024 * 1024)
|
||||
if value is not None:
|
||||
installed[str(path)] = value
|
||||
try:
|
||||
result = require_capture(
|
||||
[
|
||||
tools["python"],
|
||||
"-c",
|
||||
"import importlib.metadata,json; print(json.dumps(sorted((d.metadata['Name'],d.version) for d in importlib.metadata.distributions())))",
|
||||
],
|
||||
timeout=30,
|
||||
max_stdout=1024 * 1024,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode:
|
||||
raise ValueError("Cannot fingerprint installed Python distributions")
|
||||
installed["python_distributions"] = hashlib.sha256(result.stdout).hexdigest()
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ValueError("Cannot fingerprint Python environment") from exc
|
||||
identity["installed"] = installed
|
||||
return digest(identity)
|
||||
Reference in New Issue
Block a user