From bb9c03d597678e0eb189d9c7f9d14410169da05e Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Sun, 20 Sep 2026 11:24:06 +0200 Subject: [PATCH 1/6] chore: synchronize verified maintenance package --- docs/man/maintain-agentic-workspace.1.md | 37 ++ .../maintenance-execution-contexts.json | 13 + scripts/lib/agentic_workspace_fleet.py | 120 +++++- scripts/lib/maintenance_container_worker.py | 129 +++++++ scripts/lib/maintenance_execution_context.py | 354 ++++++++++++++++++ scripts/lib/workspace_storage_maintenance.py | 5 +- scripts/maintain-agentic-workspace.ps1 | 88 ++++- scripts/maintain-agentic-workspace.sh | 72 +++- .../test_maintenance_execution_context.py | 346 +++++++++++++++++ .../tests/test_maintenance_tui_wrappers.py | 97 +++++ 10 files changed, 1232 insertions(+), 29 deletions(-) create mode 100644 scripts/config/maintenance-execution-contexts.json create mode 100644 scripts/lib/maintenance_container_worker.py create mode 100644 scripts/lib/maintenance_execution_context.py create mode 100644 scripts/tests/test_maintenance_execution_context.py diff --git a/docs/man/maintain-agentic-workspace.1.md b/docs/man/maintain-agentic-workspace.1.md index 383c912c..ff9d93d7 100644 --- a/docs/man/maintain-agentic-workspace.1.md +++ b/docs/man/maintain-agentic-workspace.1.md @@ -18,6 +18,30 @@ pwsh -NoProfile -File scripts/maintain-agentic-workspace.ps1 [OPTIONEN] ## DESCRIPTION +Secure-Trader-Ziele werden anhand von +`scripts/config/maintenance-execution-contexts.json` einer freigegebenen +Podman-Sandbox zugeordnet. Podman ist die hier verwendete Container-Laufzeit. +Lokale Git- und Wartungsoperationen dieser Ziele haben keinen Host-Fallback. +Authentifizierte Fetches verbleiben auf dem Host; Zugangsdaten werden nicht +in den Container kopiert. Vor Fetches und Änderungen müssen Mounts, +Owner-Freigabe, Ablaufdatum und Hashbindung des eingebauten Quellpakets +übereinstimmen. Ein laufender Container allein genügt nicht. +Bei `SandboxPreflightBlocked` zuerst den Bericht prüfen und das geprüfte +Quellpaket ausdrücklich veröffentlichen sowie im Sandbox-Image pinnen. +Das Wartungsskript baut oder ersetzt kein Image automatisch. +Arbeits- und Freigabestand: +[Container-Delegation](../maintenance/container-delegation-2026-09-20.md). + +*Secure Trader targets use the approved Podman sandbox declared in +`scripts/config/maintenance-execution-contexts.json`. Podman is the container +runtime used here. Local Git and maintenance operations never fall back to +the host. Authenticated fetches stay on the host; credentials are not copied +into the container. Before fetches or changes, mounts, Owner approval, +expiry and embedded package hashes must match. A running container alone +is insufficient. For `SandboxPreflightBlocked`, inspect the report, explicitly +publish the reviewed package and pin it in the sandbox image. Maintenance +never rebuilds or replaces the image automatically.* + Ohne Optionen öffnet ein vollständig interaktives Terminal zuerst die Wartungs-TUI. TUI bedeutet Terminal User Interface, also eine textbasierte Benutzungsoberfläche im Terminal. Die Vorauswahl ist @@ -83,6 +107,19 @@ Die unterstuetzten Profilnamen und ihre Matrixdateien stehen zentral in `scripts/config/spec-kit-preset-profiles.json`. Lokale Registry-Eintraege mit unbekannten Profilen brechen weiterhin fail-closed ab. +Die lokale Registry kann `level0PresetProfile` fuer die ausfuehrende +Level-0-Quelle setzen. Fehlt dieses Feld, gilt wie bisher +`defaultPresetProfile`. Projektbezogene 14er-Freigaben erhoehen dadurch +nicht den Standard fuer andere oder neu registrierte Repositories. +`model-routing` und `storage-cleanup` sind gueltige Live-Ereignisphasen +im unveraenderten Ereignisschema 1. + +*The local registry may set `level0PresetProfile` for the executing Level-0 +source. When absent, `defaultPresetProfile` remains the fallback. A reviewed +fourteen-preset opt-in does not raise the default for other or newly +registered repositories. Model routing and storage cleanup are valid live +event phases in the unchanged event schema version 1.* + Die portable Sollquelle steht in `scripts/config/agentic-workspace-fleet.json`. Sie unterscheidet kanonische Flottenziele, Preset-Repositories und reine Collections. Der gemeinsame diff --git a/scripts/config/maintenance-execution-contexts.json b/scripts/config/maintenance-execution-contexts.json new file mode 100644 index 00000000..0154242d --- /dev/null +++ b/scripts/config/maintenance-execution-contexts.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "container": "absdd-image-sandbox_ade_1", + "user": "adedev", + "source": "/opt/home-baseline", + "approvalPath": "container-images/absdd-image-sandbox/docs/security/sandbox-freigabe.md", + "expiresOn": "2026-12-31", + "mounts": [ + {"host": "SecureCaseTrackerProjects", "container": "/secure-case-tracker-projects"}, + {"host": "SecureOrderDeskProjects", "container": "/secure-order-desk-projects"}, + {"host": "SecureServiceHarvesterProjects", "container": "/secure-service-harvester-projects"} + ] +} diff --git a/scripts/lib/agentic_workspace_fleet.py b/scripts/lib/agentic_workspace_fleet.py index b07aac7b..ad8c8a6d 100644 --- a/scripts/lib/agentic_workspace_fleet.py +++ b/scripts/lib/agentic_workspace_fleet.py @@ -24,6 +24,7 @@ VALID_KINDS = {"git-repository", "collection"} +EXECUTION_CONTEXTS = None VALID_CLASSES = {"canonical-fleet", "preset"} VALID_FORGES = {"github", "gitlab", "codeberg", "forgejo", "generic-git"} KNOWN_MSL_LANGUAGES = { @@ -56,6 +57,11 @@ def utc_now() -> str: def run_git(repository: pathlib.Path | None, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + if EXECUTION_CONTEXTS is not None and repository is not None and EXECUTION_CONTEXTS.mapping(repository): + result = EXECUTION_CONTEXTS.git(repository, arguments) + if check and result.returncode: + raise ContractError("Delegated Git operation failed") + return result command = ["git"] if repository is not None: command.extend(["-C", str(repository)]) @@ -2896,10 +2902,18 @@ def resolve_default_branch_evidence( def classify_repository( target: dict, path: pathlib.Path, mode: str, allowed_dirty_paths: set[str] | None = None ) -> dict: - if path.is_symlink() or (path.exists() and not path.is_dir()): + delegated = EXECUTION_CONTEXTS is not None and EXECUTION_CONTEXTS.mapping(path) + probe = EXECUTION_CONTEXTS.request(path, "probe") if delegated else None + exists = probe["exists"] if probe is not None else path.exists() + directory = probe["directory"] if probe is not None else path.is_dir() + git_directory = probe["git"] if probe is not None else (path / ".git").exists() + if (not delegated and path.is_symlink()) or (exists and not directory): return target_result(target, status="PATH_CONFLICT", result="Blocked", findingCode="PathConflict", nextAction="Konfliktpfad nach manueller Prüfung entfernen oder verschieben / remove or relocate it after review.") - if not path.exists(): + if not exists: + if delegated: + return target_result(target, status="MISSING", result="Blocked", findingCode="MissingSandboxTarget", + nextAction="Ziel separat in der Sandbox bereitstellen / provision the target separately in the sandbox.") if mode == "check-only": return target_result(target, status="MISSING", action="CLONE_REQUIRED", result="Blocked", findingCode="MissingTarget", nextAction="Nach Remote-Prüfung im Update-Modus ausführen / run update after reviewing the remote.") @@ -2907,7 +2921,7 @@ def classify_repository( return target_result(target, status="MISSING", action="WOULD_CLONE", result="Warning", findingCode="MissingTarget", nextAction="Update-Modus zum Klonen ausführen / run update to clone this target.") return clone_repository(target, path) - if not (path / ".git").exists(): + if not git_directory: return target_result(target, status="PATH_CONFLICT", result="Blocked", findingCode="PathConflict", nextAction="Nicht-Git-Verzeichnis prüfen; es wird nie automatisch entfernt / review the directory; it is never removed.") @@ -3393,19 +3407,35 @@ def recover_worktree_leases(args: argparse.Namespace) -> int: def execute_fleet(args: argparse.Namespace) -> int: + global EXECUTION_CONTEXTS + EXECUTION_CONTEXTS = None started = time.monotonic() started_at = utc_now() run_id = args.run_id or str(uuid.uuid4()) try: manifest = load_manifest(args.manifest) + context_evidence = None + if getattr(args, "execution_contract", None): + from maintenance_execution_context import ExecutionContexts, ExecutionContextError + try: + EXECUTION_CONTEXTS = ExecutionContexts(args.execution_contract, args.home_dir, manifest["targets"]) + context_evidence = EXECUTION_CONTEXTS.preflight() + except (ExecutionContextError, OSError, ValueError, subprocess.SubprocessError) as exc: + raise ContractError(f"SandboxPreflightBlocked: {exc}") from exc except ContractError as exc: + failure_code = "SandboxPreflightBlocked" if str(exc).startswith("SandboxPreflightBlocked:") else "ManifestInvalid" + next_action = ("Sandbox-Quellpaket und Freigabe prüfen / inspect sandbox source package and approval." + if failure_code == "SandboxPreflightBlocked" else + "Manifest korrigieren und erneut ausführen / correct the manifest and retry.") report = { "schemaVersion": "1.0", "runId": run_id, "platform": sys.platform, "mode": args.mode, "startedAt": started_at, "completedAt": utc_now(), "overallStatus": "FAILED", "exitCode": 2, "stages": [{"stageId": "fleet", "status": "Failed", "exitCode": 2, "durationMs": 0, - "summary": str(exc), "nextAction": "Manifest korrigieren und erneut ausführen / correct the manifest and retry."}], - "targets": [], "toolchain": [], "findings": [{"code": "ManifestInvalid", "severity": "Fatal", - "summary": str(exc), "nextAction": "Manifest korrigieren und erneut ausführen / correct the manifest and retry."}], + "summary": str(exc), "nextAction": next_action}], + "targets": [], "toolchain": [], "findings": [{"code": failure_code, "severity": "Fatal", + "summary": str(exc), "nextAction": next_action}], + "mutationBarrier": {"allFetchAttemptsCompleted": False, "fleetReady": False, + "domainMutationAllowed": False, "nextAction": next_action}, "artifacts": {"logPath": str(args.log), "reportPath": str(args.report)} } write_report(args.report, report) @@ -3429,7 +3459,7 @@ def execute_fleet(args: argparse.Namespace) -> int: "defaultBranch": branch.stdout.strip(), } level0_result = classify_repository( - level0_target, level0, args.mode, allowed_dirty_paths + level0_target, level0, "dry-run" if EXECUTION_CONTEXTS and args.mode == "update" else args.mode, allowed_dirty_paths ) results.append(level0_result) print( @@ -3456,12 +3486,57 @@ def execute_fleet(args: argparse.Namespace) -> int: continue relative = validate_relative_path(target["path"]) target_path = home.joinpath(*relative.parts) - result = (collection_result(target, target_path, args.mode) - if target["kind"] == "collection" - else classify_repository(target, target_path, args.mode, allowed_dirty_paths)) + evaluation_mode = "dry-run" if EXECUTION_CONTEXTS and args.mode == "update" else args.mode + try: + result = (collection_result(target, target_path, evaluation_mode) + if target["kind"] == "collection" + else classify_repository(target, target_path, evaluation_mode, allowed_dirty_paths)) + except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as exc: + if not EXECUTION_CONTEXTS: + raise + result = target_result(target, status="UNAVAILABLE", result="Failed", + findingCode="ExecutionContextLost", mutationAllowed=False, + nextAction="Sandbox prüfen; kein Host-Fallback / inspect sandbox; no host fallback.") + result["executionContext"] = "container" if (target["kind"] == "git-repository" and + EXECUTION_CONTEXTS and EXECUTION_CONTEXTS.mapping(target_path)) else "host" results.append(result) print(f"TARGET\t{target['id']}\t{result['status']}\t{result['action']}\t{result['nextAction']}") + # DE: Erst alle Fetches und Statuspruefungen, danach lokale Fast-forwards. + # EN: Finish every fetch and status check before any local fast-forward. + if EXECUTION_CONTEXTS and args.mode == "update": + ready = all(item["result"] == "Pass" or (item["status"] == "BEHIND" and item["action"] == "WOULD_PULL") for item in results) + if ready: + for item in results: + if item["status"] != "BEHIND": + continue + repository = args.level0_dir if item["targetId"] == "level0" else home / item["path"] + try: + dirty = run_git(repository, "status", "--porcelain=v1", "--untracked-files=all").stdout + branch = run_git(repository, "symbolic-ref", "--quiet", "--short", "HEAD").stdout.strip() + expected = item["defaultBranchEvidence"]["trackingCommit"] + current = run_git(repository, "rev-parse", item["upstream"]).stdout.strip() + result = None + if not dirty and branch == item["branch"] and current == expected: + result = run_git(repository, "merge", "--ff-only", expected, check=False) + if result.returncode == 0 and run_git(repository, "rev-parse", "HEAD").stdout.strip() != expected: + result = None + except (ValueError, RuntimeError, OSError, subprocess.SubprocessError): + result = None + if result is None or result.returncode: + item.update(status="UNAVAILABLE", result="Failed", findingCode="FastForwardFailed", mutationAllowed=False) + break + item.update(status="UPDATED", action="PULL", result="Pass", behind=0, findingCode="N/A", mutationAllowed=True, nextAction="N/A") + try: + refreshed_context = EXECUTION_CONTEXTS.preflight() + if refreshed_context != context_evidence: + raise ContractError("Execution binding changed after fetch/fast-forward") + except (ValueError, RuntimeError, OSError, subprocess.SubprocessError): + # Pulling Level 0 can change the package underneath this + # process. Close the domain gate before Home sync in that case. + results[0].update(status="UNAVAILABLE", result="Failed", mutationAllowed=False, + findingCode="SandboxBindingChanged", + nextAction="Sandbox-Quellbindung erneuern / refresh the sandbox source binding.") overall, exit_code = derive_status(results, args.mode) findings = [ {"targetId": item["targetId"], "code": item["findingCode"], @@ -3539,6 +3614,8 @@ def execute_fleet(args: argparse.Namespace) -> int: }, "artifacts": {"logPath": str(args.log), "reportPath": str(args.report)} } + if context_evidence: + report["executionContexts"] = [context_evidence, {"context": "host", "networkGit": True}] write_report(args.report, report) print(f"SUMMARY\tfleet\t{overall}\t{exit_code}\t{args.report}") return exit_code @@ -3907,6 +3984,10 @@ def list_canonical_repositories(args: argparse.Namespace) -> int: return 2 home = args.home_dir.resolve() + router = None + if getattr(args, "exclude_execution_contract", None): + from maintenance_execution_context import ExecutionContexts + router = ExecutionContexts(args.exclude_execution_contract, home, manifest["targets"]) repositories: list[tuple[int, pathlib.Path]] = [] for target in manifest["targets"]: if ( @@ -3916,6 +3997,8 @@ def list_canonical_repositories(args: argparse.Namespace) -> int: ): continue relative = validate_relative_path(target["path"]) + if router and router.mapping(home.joinpath(*relative.parts)): + continue repository = home.joinpath(*relative.parts).resolve() try: repository.relative_to(home) @@ -3934,6 +4017,15 @@ def list_canonical_repositories(args: argparse.Namespace) -> int: return 0 +def execution_required(args): + from maintenance_execution_context import validate_contract + manifest = load_manifest(args.manifest) + contract = validate_contract(json.loads(args.contract.read_text(encoding="utf-8")), check_expiry=False) + roots = {item["host"] for item in contract["mounts"]} + print("1" if any(item["active"] and item["path"].split("/")[0] in roots for item in manifest["targets"]) else "0") + return 0 + + def print_default_remote_ref(args: argparse.Namespace) -> int: repository = args.repository.resolve() origin = run_git(repository, "remote", "get-url", "origin", check=False) @@ -4963,6 +5055,7 @@ def build_parser() -> argparse.ArgumentParser: fleet.add_argument("--run-id") fleet.add_argument("--allowed-dirty-path", action="append", default=[]) fleet.add_argument("--level0-dir", type=pathlib.Path) + fleet.add_argument("--execution-contract", type=pathlib.Path) fleet.set_defaults(handler=execute_fleet) stage = subparsers.add_parser("stage") stage.add_argument("--report", type=pathlib.Path, required=True) @@ -5023,7 +5116,12 @@ def build_parser() -> argparse.ArgumentParser: repositories.add_argument("--manifest", type=pathlib.Path, required=True) repositories.add_argument("--home-dir", type=pathlib.Path, required=True) repositories.add_argument("--existing-only", action="store_true") + repositories.add_argument("--exclude-execution-contract", type=pathlib.Path) repositories.set_defaults(handler=list_canonical_repositories) + execution = subparsers.add_parser("execution-required") + execution.add_argument("--manifest", type=pathlib.Path, required=True) + execution.add_argument("--contract", type=pathlib.Path, required=True) + execution.set_defaults(handler=execution_required) default_ref = subparsers.add_parser("default-ref") default_ref.add_argument("--repository", type=pathlib.Path, required=True) default_ref.set_defaults(handler=print_default_remote_ref) @@ -5066,6 +5164,8 @@ def build_parser() -> argparse.ArgumentParser: "propagation", "preset-profiles", "toolchain", + "model-routing", + "storage-cleanup", "final", ), ) diff --git a/scripts/lib/maintenance_container_worker.py b/scripts/lib/maintenance_container_worker.py new file mode 100644 index 00000000..682361b5 --- /dev/null +++ b/scripts/lib/maintenance_container_worker.py @@ -0,0 +1,129 @@ +"""Leaf-phase executor inside the approved sandbox; never a full orchestrator.""" +from __future__ import annotations + +import json +import pathlib +import subprocess +import tempfile + + +def execute(payload: dict) -> dict: + source = pathlib.Path(payload["source"]) + mode = payload["mode"] + if mode not in ("check-only", "dry-run", "update"): + raise ValueError("Invalid delegated mode") + if payload["phase"] not in ("registry", "propagation", "preset-profiles", "storage-cleanup"): + raise ValueError("Invalid delegated phase") + records = [] + updated = [] + with tempfile.TemporaryDirectory(prefix="hb-maintenance-") as temporary: + workspace = pathlib.Path(temporary) + registry = workspace / "registry.json" + entries = payload["entries"] + for entry in entries: + repo = pathlib.Path(entry["containerPath"]) + root = pathlib.Path(entry["containerRoot"]) + if repo.resolve() != repo or root.resolve() != root: + raise ValueError("Delegated path has symlink components") + repo.relative_to(root) + if (repo / ".git").is_symlink() or not (repo / ".git").is_dir(): + raise ValueError("Missing delegated Git target") + registry.write_text(json.dumps({ + "schemaVersion": 1, + "defaultPresetProfile": payload["defaultPresetProfile"], + "repositories": [{**entry["registry"], "path": entry["containerPath"]} for entry in entries], + }), encoding="utf-8") + for entry in entries: + repo = entry["containerPath"] + phase = payload["phase"] + command = [] + if phase == "registry": + command = ["bash", str(source / "scripts/register-level2-repository.sh"), + "--repo", repo, "--level", str(entry["registry"]["level"]), + "--registry", str(registry), "--source", "maintenance-discovery"] + # The temporary registry is always writable; only an approved + # update result may be returned to the host registry owner. + elif phase == "propagation": + command = ["bash", str(source / "scripts/propagate-agentic-toolchain-maintenance.sh"), + "--repo", repo, "--home-dir", "/", "--registry", str(registry)] + # Checks also run during preview so drift never becomes a + # successful result merely because a dry-run returned zero. + command += ["--check-only"] + elif phase == "preset-profiles": + profile = entry["registry"].get("presetProfile", payload["defaultPresetProfile"]) + if profile == "none": + records.append({"targetId": entry["targetId"], "status": "Skipped", "reason": "ProfileNone"}) + continue + catalog = json.loads((source / "scripts/config/spec-kit-preset-profiles.json").read_text()) + profiles = catalog["profiles"] + selected = profiles[profile] + config = selected.get("configPath", selected.get("presetConfig")) + if not isinstance(config, str) or pathlib.PurePosixPath(config).is_absolute() or ".." in pathlib.PurePosixPath(config).parts: + raise ValueError("Invalid preset matrix path") + local = subprocess.run(["git", "-C", repo, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout + upstream = subprocess.run(["git", "-C", repo, "rev-parse", "origin/HEAD"], capture_output=True, text=True, check=True).stdout + if local != upstream: + records.append({"targetId": entry["targetId"], "status": "Blocked", "reason": "CanonicalPresetWorktreeRequired"}) + continue + command = ["bash", str(source / "scripts/install-spec-kit-governance-presets.sh"), + "--repo", repo, "--preset-config", str(source / config), + "--check-only"] + elif phase == "storage-cleanup": + continue + result = subprocess.run(command, capture_output=True, text=True, timeout=600) + output = result.stdout + result.stderr + status = "Passed" if result.returncode == 0 else "Blocked" if result.returncode == 1 else "Failed" + if phase == "registry" and mode != "update" and any(line.startswith(("updated:", "added:")) for line in output.splitlines()): + status = "Blocked" + records.append({"targetId": entry["targetId"], "status": status, + "exitCode": result.returncode, "output": output}) + # No implicit repair. The caller must explicitly pass repairDrift. + if phase in ("propagation", "preset-profiles") and result.returncode == 1 and mode == "update" and payload["repairDrift"]: + dirty = subprocess.run(["git", "-C", repo, "status", "--porcelain=v1", "--untracked-files=all"], + capture_output=True, text=True, timeout=30, check=True) + if dirty.stdout: + records[-1].update(status="Blocked", reason="DirtyWorktreeRequiresReview") + continue + preview = command[:-1] + ["--dry-run"] + preview_result = subprocess.run(preview, capture_output=True, text=True, timeout=600) + if preview_result.returncode: + records[-1]["status"] = "Failed" + continue + update = command[:-1] + (["--force"] if phase == "preset-profiles" else []) + applied = subprocess.run(update, capture_output=True, text=True, timeout=600) + checked = subprocess.run(command, capture_output=True, text=True, timeout=600) if applied.returncode == 0 else applied + records[-1].update(status="Passed" if checked.returncode == 0 else "Failed", exitCode=checked.returncode, + repaired=checked.returncode == 0, + output=output + applied.stdout + applied.stderr + checked.stdout + checked.stderr) + if payload["phase"] == "registry": + updated = json.loads(registry.read_text())["repositories"] + if payload["phase"] == "storage-cleanup": + # Only project outputs belong to the delegated phase. Host caches, + # container images and persistent agent volumes are not candidates. + import workspace_storage_maintenance as storage + storage_registry = json.loads(registry.read_text()) + for entry in storage_registry["repositories"]: + entry["path"] = entry["path"].lstrip("/") + registry.write_text(json.dumps(storage_registry)) + result_file = workspace / "storage.json" + args = storage.build_parser().parse_args([ + "--home-dir", "/", "--registry", str(registry), + "--policy", str(source / "scripts/config/workspace-storage-maintenance.json"), + "--report", str(result_file), "--mode", mode, "--profile", payload["cleanupProfile"], + "--run-id", payload["runId"], "--projects-only"]) + args.confirm_deep_cleanup = payload.get("confirmDeepCleanup") is True + if mode == "update": + args.mode = "dry-run" + preview_code = storage.execute(args) + preview = json.loads(result_file.read_text()) + if preview_code or preview.get("overallStatus") != "SUCCESS": + records.append({"status": "Blocked", "exitCode": preview_code, "storage": preview}) + return {"records": records, "registry": [], "status": "Blocked"} + args.mode = mode + code = storage.execute(args) + storage_result = json.loads(result_file.read_text()) + records.append({"status": "Failed" if code else "Blocked" if storage_result.get("warnings") else "Passed", + "exitCode": code, "storage": storage_result}) + return {"records": records, "registry": updated, "repaired": any(r.get("repaired") for r in records), + "status": "Failed" if any(r["status"] == "Failed" for r in records) else + "Blocked" if any(r["status"] == "Blocked" for r in records) else "Passed"} diff --git a/scripts/lib/maintenance_execution_context.py b/scripts/lib/maintenance_execution_context.py new file mode 100644 index 00000000..793626c5 --- /dev/null +++ b/scripts/lib/maintenance_execution_context.py @@ -0,0 +1,354 @@ +"""Bounded Podman transport shared by the Bash and PowerShell orchestrators. + +DE: Netzwerk-Git bleibt auf dem Host; lokale Repo-Operationen haben keinen +Host-Fallback. Container-IDs werden pro Lauf gebunden, nicht konfiguriert. +EN: Network Git stays on the host; local repository operations never fall +back to the host. Container IDs are bound per run, not configured. +""" +from __future__ import annotations + +import datetime +import json +import pathlib +import re +import subprocess +import hashlib +import argparse +import os +import sys +import stat + + +class ExecutionContextError(ValueError): + """A missing or changed boundary blocks execution.""" + + +def relative_path(value: str) -> pathlib.PurePosixPath: + if not isinstance(value, str) or not value or "\\" in value or ":" in value or any(ord(c) < 32 for c in value): + raise ExecutionContextError("Invalid relative path") + path = pathlib.PurePosixPath(value) + if path.is_absolute() or any(part in ("", ".", "..") for part in value.split("/")): + raise ExecutionContextError("Unsafe relative path") + return path + + +def validate_contract(data: dict, today: datetime.date | None = None, *, check_expiry: bool = True) -> dict: + expected = {"schemaVersion", "container", "user", "source", "approvalPath", "expiresOn", "mounts"} + if not isinstance(data, dict) or set(data) != expected or type(data["schemaVersion"]) is not int or data["schemaVersion"] != 1: + raise ExecutionContextError("Invalid execution context contract") + for key in ("container", "user"): + if not isinstance(data[key], str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]*", data[key]): + raise ExecutionContextError(f"Invalid {key}") + relative_path(data["approvalPath"]) + if not isinstance(data["expiresOn"], str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", data["expiresOn"]): + raise ExecutionContextError("Invalid approval expiry") + try: + expires = datetime.date.fromisoformat(data["expiresOn"]) + except (ValueError, TypeError) as exc: + raise ExecutionContextError("Invalid approval expiry") from exc + if check_expiry and (today or datetime.datetime.now(datetime.timezone.utc).date()) > expires: + raise ExecutionContextError("Sandbox approval expired") + mounts = data["mounts"] + if not isinstance(mounts, list) or not mounts: + raise ExecutionContextError("Missing mount mappings") + hosts, destinations = set(), set() + for item in mounts: + if not isinstance(item, dict) or set(item) != {"host", "container"}: + raise ExecutionContextError("Invalid mount mapping") + host = relative_path(item["host"]) + destination = item["container"] + if not isinstance(destination, str) or not destination.startswith("/"): + raise ExecutionContextError("Container path must be absolute") + relative_path(destination[1:]) + if len(host.parts) != 1 or len(pathlib.PurePosixPath(destination).parts) != 2: + raise ExecutionContextError("Mount mappings must name distinct workspace roots") + if str(host).casefold() in hosts or destination in destinations: + raise ExecutionContextError("Duplicate mount mapping") + hosts.add(str(host).casefold()) + destinations.add(destination) + if not isinstance(data["source"], str) or not data["source"].startswith("/"): + raise ExecutionContextError("Invalid source path") + relative_path(data["source"][1:]) + return data + + +# This small, fixed program runs *inside* Podman. Arguments are JSON, never +# shell fragments. Resolve every path component there before touching Git. +REMOTE_PROGRAM = r''' +import json, pathlib, re, subprocess, sys +p = json.load(sys.stdin) +root = pathlib.Path(p["root"]) +repo = pathlib.Path(p["repository"]) +if root.resolve() != root or repo.resolve() != repo: + raise SystemExit("Symlink boundary rejected") +repo.relative_to(root) +if (repo / ".git").is_symlink(): + raise SystemExit("Symlink Git directory rejected") +if p["action"] == "probe": + print(json.dumps({"exists":repo.exists(), "directory":repo.is_dir(), + "git":(repo / ".git").is_dir()})) +elif p["action"] == "git": + if not (repo / ".git").is_dir(): + raise SystemExit("Missing declared Git repository") + args = p["arguments"] + allowed = {"remote", "symbolic-ref", "rev-parse", "rev-list", "status", "cat-file", "merge", "diff", "log"} + effective = args[2:] if args[:2] == ["-c", "core.quotePath=false"] else args + if not effective or effective[0] not in allowed: + raise SystemExit("Git operation outside local maintenance contract") + if effective[0] == "remote" and effective != ["remote", "get-url", "origin"]: + raise SystemExit("Remote mutation is not allowed") + if effective[0] == "symbolic-ref" and effective not in ( + ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], + ["symbolic-ref", "--quiet", "--short", "HEAD"]): + raise SystemExit("Symbolic-ref mutation is not allowed") + if effective[0] == "merge" and (len(effective) != 3 or effective[1] != "--ff-only" or not re.fullmatch(r"[0-9a-f]{40,64}", effective[2])): + raise SystemExit("Only bounded fast-forward merge is allowed") + result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True) + print(json.dumps({"returncode":result.returncode,"stdout":result.stdout,"stderr":result.stderr})) +else: + raise SystemExit("Unknown maintenance action") +''' + +PHASE_PROGRAM = r''' +import contextlib, hashlib, importlib, io, json, pathlib, sys +p = json.load(sys.stdin) +source = pathlib.Path(p["source"]) +for relative, expected in p["bindings"].items(): + path = source / relative + if path.resolve() != path or hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != expected: + raise SystemExit("Delegated source binding mismatch") +sys.path.insert(0, str(source / "scripts/lib")) +worker = importlib.import_module("maintenance_container_worker") +log = io.StringIO() +with contextlib.redirect_stdout(log): + result = worker.execute(p) +result["log"] = log.getvalue() +print(json.dumps(result)) +''' + + +SOURCE_PROGRAM = r''' +import hashlib, json, os, pathlib, shutil, sys +p = json.load(sys.stdin) +source = pathlib.Path(p["source"]) +if source.resolve() != source or not source.is_dir() or os.access(source, os.W_OK): + raise SystemExit("Canonical source must be immutable to the sandbox user") +if not all(shutil.which(t) for t in ("git", "bash", "pwsh", "python3")): + raise SystemExit("Missing maintenance tool") +for relative, expected in p["bindings"].items(): + path = source / relative + if path.resolve() != path or not path.is_file() or os.access(path, os.W_OK): + raise SystemExit("Missing or writable source: " + relative) + if hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != expected: + raise SystemExit("Source binding mismatch: " + relative) +print("ready") +''' + + +def source_bindings(source: pathlib.Path) -> dict: + """Bind only declared package members, including the declaration itself.""" + manifest = "scripts/config/agentic-toolchain-maintenance-files.json" + package = json.loads((source / manifest).read_text(encoding="utf-8")) + paths = {manifest, *(entry["path"] for entry in package["files"])} + paths.update({"scripts/lib/maintenance_execution_context.py", + "scripts/lib/maintenance_container_worker.py", + "scripts/config/maintenance-execution-contexts.json"}) + bindings = {} + source = source.resolve() + for relative in sorted(paths): + relative_path(relative) + path = source / relative + if path.resolve() != path or not path.is_file(): + raise ExecutionContextError("Unsafe or missing package source: " + relative) + # The declared package consists of text files. Match repository text + # across Windows CRLF and the Linux image without ignoring other bytes. + bindings[relative] = hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() + return bindings + + +def filter_registry(router, registry): + return {**registry, "repositories": [entry for entry in registry["repositories"] + if not router.mapping(router.home / entry["path"])]} + + +def run_phase(args): + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + router = ExecutionContexts(args.contract, args.home_dir, manifest["targets"]) + report = json.loads(args.report.read_text(encoding="utf-8")) + evidence = router.preflight(args.source) + previous = next((item for item in report.get("executionContexts", []) if item["context"] == "container"), None) + if previous is None or any(previous[key] != evidence[key] for key in ("containerId", "image", "approvalSha256", "sourceSha256")): + raise ExecutionContextError("Delegation no longer matches fleet preflight") + registry_bytes = args.registry.read_bytes() + if args.registry.is_symlink(): + raise ExecutionContextError("Registry symlink is not a writable delegation target") + registry = json.loads(registry_bytes) + if args.phase == "host-registry": + args.output.write_text(json.dumps(filter_registry(router, registry)), encoding="utf-8") + return 0 + mode = args.mode + if mode == "update" and report.get("mutationBarrier", {}).get("domainMutationAllowed") is not True: + raise ExecutionContextError("Fleet mutation barrier is closed") + targets = {item["path"]: item for item in manifest["targets"] if item["active"]} + entries = [] + for entry in registry["repositories"]: + mapping = router.mapping(router.home / entry["path"]) + if mapping: + entries.append({"registry": entry, "targetId": targets[entry["path"]]["id"], + "containerRoot": mapping[0], "containerPath": mapping[1]}) + expected = {item["path"] for item in manifest["targets"] if item["active"] and router.mapping(router.home / item["path"])} + if {entry["registry"]["path"] for entry in entries} != expected: + raise ExecutionContextError("Registry does not cover every delegated target") + bindings = source_bindings(args.source) + payload = {"source": router.data["source"], "mode": mode, "phase": args.phase, + "entries": entries, "bindings": bindings, "runId": report["runId"], + "defaultPresetProfile": registry.get("defaultPresetProfile", "standard-eight-governance-presets"), + "repairDrift": args.repair_drift, "cleanupProfile": args.cleanup_profile, + "confirmDeepCleanup": args.confirm_deep_cleanup} + result = subprocess.run(["podman", "exec", "-i", router.container_id, "python3", "-c", PHASE_PROGRAM], + input=json.dumps(payload), text=True, capture_output=True, timeout=3600) + if result.returncode: + raise ExecutionContextError("Container phase failed (source drift, unavailable tool or lost sandbox); no host fallback") + output = json.loads(result.stdout) + output.update(phase=args.phase, executionContext="container", containerId=router.container_id) + if args.phase == "registry" and mode == "update" and output["status"] == "Passed": + translated = {entry["containerPath"]: entry["registry"]["path"] for entry in entries} + replacement = {} + for entry in output["registry"]: + path = translated[entry["path"]] + replacement[path] = {**entry, "path": path} + if set(replacement) != expected or args.registry.read_bytes() != registry_bytes: + raise ExecutionContextError("Concurrent or unexpected registry change") + registry["repositories"] = [replacement.get(entry["path"], entry) for entry in registry["repositories"]] + temporary = args.registry.with_name(args.registry.name + ".delegation-tmp") + with temporary.open("x", encoding="utf-8") as stream: + json.dump(registry, stream, ensure_ascii=False, indent=2) + stream.write("\n") + os.chmod(temporary, stat.S_IMODE(args.registry.stat().st_mode)) + os.replace(temporary, args.registry) + report.setdefault("delegatedPhases", []).append(output) + from agentic_workspace_fleet import write_report + write_report(args.report, report) + for row in output["records"]: + print(f"CONTAINER\t{args.phase}\t{row.get('targetId', 'storage')}\t{row['status']}") + if output["status"] == "Passed" and output.get("repaired"): + return 3 + return {"Passed": 0, "Blocked": 1, "Failed": 2}[output["status"]] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--contract", type=pathlib.Path, required=True) + parser.add_argument("--manifest", type=pathlib.Path, required=True) + parser.add_argument("--home-dir", type=pathlib.Path, required=True) + parser.add_argument("--source", type=pathlib.Path, required=True) + parser.add_argument("--registry", type=pathlib.Path, required=True) + parser.add_argument("--report", type=pathlib.Path, required=True) + parser.add_argument("--phase", choices=("registry", "propagation", "preset-profiles", "storage-cleanup", "host-registry"), required=True) + parser.add_argument("--mode", choices=("check-only", "dry-run", "update"), required=True) + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--repair-drift", action="store_true") + parser.add_argument("--cleanup-profile", choices=("none", "safe", "deep"), default="safe") + parser.add_argument("--confirm-deep-cleanup", action="store_true") + args = parser.parse_args() + try: + return run_phase(args) + except (ExecutionContextError, OSError, ValueError, KeyError, subprocess.SubprocessError) as exc: + print(f"CONTAINER\t{args.phase}\tBLOCKED\t{exc}", file=sys.stderr) + return 2 + + +class ExecutionContexts: + def __init__(self, contract: pathlib.Path, home: pathlib.Path, targets: list[dict]): + self.data = validate_contract(json.loads(contract.read_text(encoding="utf-8"))) + self.home = home.absolute() + self.targets = {item["path"] for item in targets if item.get("active") and item.get("kind") == "git-repository"} + self.container_id = None + self.image = None + + def mapping(self, repository: pathlib.Path) -> tuple[str, str] | None: + try: + relative = repository.absolute().relative_to(self.home).as_posix() + except ValueError: + return None + for item in self.data["mounts"]: + root = item["host"] + if relative == root or relative.startswith(root + "/"): + if relative not in self.targets: + raise ExecutionContextError("Undeclared sandbox target") + suffix = relative[len(root):] + return item["container"], item["container"] + suffix + return None + + def inspect(self) -> dict: + try: + process = subprocess.run(["podman", "inspect", self.container_id or self.data["container"]], + capture_output=True, text=True, timeout=30, check=True) + rows = json.loads(process.stdout) + if len(rows) != 1: + raise ExecutionContextError("Ambiguous container") + info = rows[0] + except (OSError, subprocess.SubprocessError, ValueError) as exc: + raise ExecutionContextError("Sandbox unavailable; no host fallback") from exc + if not info.get("State", {}).get("Running") or info.get("Config", {}).get("User") != self.data["user"]: + raise ExecutionContextError("Sandbox stopped or unexpected user") + if self.container_id and (info["Id"] != self.container_id or info["Image"] != self.image): + raise ExecutionContextError("Sandbox identity changed during run") + for mapping in self.data["mounts"]: + matches = [m for m in info.get("Mounts", []) if m.get("Destination") == mapping["container"]] + if len(matches) != 1 or matches[0].get("Type") != "bind" or not matches[0].get("RW"): + raise ExecutionContextError("Missing writable workspace mount") + if pathlib.Path(matches[0]["Source"]).absolute() != self.home / mapping["host"]: + raise ExecutionContextError("Workspace mount source mismatch") + return info + + def preflight(self, source: pathlib.Path | None = None) -> dict: + validate_contract(self.data) + # Owner evidence is read on the control plane; this grants no new role. + approval = self.home.joinpath(*relative_path(self.data["approvalPath"]).parts) + if approval.resolve() != approval or not approval.is_file(): + raise ExecutionContextError("Owner approval evidence missing or symlinked") + evidence = approval.read_text(encoding="utf-8") + expiry_label = datetime.date.fromisoformat(self.data["expiresOn"]).strftime("%d.%m.%Y") + if expiry_label not in evidence or "Owner" not in evidence: + raise ExecutionContextError("Owner approval evidence does not match the bounded contract") + info = self.inspect() + self.container_id, self.image = info["Id"], info["Image"] + bindings = source_bindings(source or pathlib.Path(__file__).resolve().parents[2]) + result = subprocess.run( + ["podman", "exec", "-i", self.container_id, "python3", "-c", SOURCE_PROGRAM], + input=json.dumps({"source": self.data["source"], "bindings": bindings}), + text=True, capture_output=True, timeout=60) + if result.returncode: + raise ExecutionContextError("Sandbox source binding or required tools invalid; publish and pin the reviewed package before maintenance") + return {"context": "container", "containerId": self.container_id, "image": self.image, + "expiresOn": self.data["expiresOn"], "approvalPath": self.data["approvalPath"], + "approvalSha256": hashlib.sha256(evidence.encode("utf-8")).hexdigest(), + "sourceSha256": hashlib.sha256(json.dumps(bindings, sort_keys=True).encode()).hexdigest()} + + def request(self, repository: pathlib.Path, action: str, arguments: tuple[str, ...] = ()) -> dict: + mapping = self.mapping(repository) + if mapping is None: + raise ExecutionContextError("Target is not delegated") + if self.container_id is None: + raise ExecutionContextError("Sandbox preflight required") + self.inspect() + process = subprocess.run( + ["podman", "exec", "-i", self.container_id, "python3", "-c", REMOTE_PROGRAM], + input=json.dumps({"root": mapping[0], "repository": mapping[1], "action": action, + "arguments": arguments}), text=True, capture_output=True, timeout=120) + if process.returncode: + raise ExecutionContextError("Container maintenance request failed; no host fallback") + try: + return json.loads(process.stdout) + except ValueError as exc: + raise ExecutionContextError("Malformed container response") from exc + + def git(self, repository: pathlib.Path, arguments: tuple[str, ...]) -> subprocess.CompletedProcess: + result = self.request(repository, "git", arguments) + return subprocess.CompletedProcess(["podman", "exec", "git", *arguments], + result["returncode"], result["stdout"], result["stderr"]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lib/workspace_storage_maintenance.py b/scripts/lib/workspace_storage_maintenance.py index 0f5e6022..02a8b9ad 100755 --- a/scripts/lib/workspace_storage_maintenance.py +++ b/scripts/lib/workspace_storage_maintenance.py @@ -1139,7 +1139,7 @@ def execute(args: argparse.Namespace) -> int: if candidate.get("eligible") ) - result["providers"].extend(handle_cache_providers( + result["providers"].extend([] if getattr(args, "projects_only", False) else handle_cache_providers( home, mode=args.mode, profile=args.profile, @@ -1147,7 +1147,7 @@ def execute(args: argparse.Namespace) -> int: retention_days=retention_days, processes=processes, )) - result["providers"].extend(handle_container_provider( + result["providers"].extend([] if getattr(args, "projects_only", False) else handle_container_provider( mode=args.mode, pressure_mode=pressure_mode, retention_days=retention_days, @@ -1211,6 +1211,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--profile", choices=sorted(VALID_PROFILES), default="safe") parser.add_argument("--confirm-deep-cleanup", action="store_true") parser.add_argument("--run-id") + parser.add_argument("--projects-only", action="store_true", help="Inventory project outputs only; no global caches or container cleanup") return parser diff --git a/scripts/maintain-agentic-workspace.ps1 b/scripts/maintain-agentic-workspace.ps1 index e50e4520..5f2645dd 100644 --- a/scripts/maintain-agentic-workspace.ps1 +++ b/scripts/maintain-agentic-workspace.ps1 @@ -3,6 +3,16 @@ Wartet Workspace/Toolchain und führt Stage B sicher aus. / Maintains the workspace/toolchain and safely runs Stage B. .DESCRIPTION + Secure-Trader-Ziele benötigen die konfigurierte Podman-Sandbox mit + gültiger Owner-Freigabe und identischem, unveränderlichem Quellpaket. + Die Vorprüfung blockiert vor Fetches und Änderungen. Lokale Operationen + dieser Ziele haben keinen Host-Fallback; Zugangsdaten bleiben auf dem Host. + + Secure Trader targets require the configured Podman sandbox with valid + Owner approval and an identical immutable source package. Preflight blocks + before fetches or changes. Local operations never fall back to the host; + provider credentials remain on the host. + Completes bounded fetch attempts for Level 0 and every active Git target before any domain mutation. Only clean canonical behind-only branches are fast-forwarded. It then synchronizes the local home baseline, checks the @@ -786,6 +796,9 @@ if ($PlainUi) { } $registry = Join-Path $HomeDir '.home-baseline/level2-repository-registry.json' +$executionContract = Join-Path $sourceRoot 'scripts/config/maintenance-execution-contexts.json' +$containerExecution = $false +$domainRegistry = $registry $stateDir = Join-Path $HomeDir '.home-baseline' $lockDir = Join-Path $stateDir 'locks/agentic-workspace-maintenance.lock' $logDir = Join-Path $stateDir 'logs' @@ -1080,6 +1093,7 @@ function Invoke-HBFleetContract { foreach ($path in $script:ResumeAllowedPaths) { $arguments += @('--allowed-dirty-path', $path) } + if ($containerExecution) { $arguments += @('--execution-contract', $executionContract) } Invoke-HBPythonCommand -Arguments $arguments | ForEach-Object { Write-Host $_ } $status = $LASTEXITCODE return [int]$status @@ -1238,12 +1252,15 @@ function Test-HBRepository { } function Get-HBManagedRepositories { + $executionArguments = @() + if ($containerExecution) { $executionArguments = @('--exclude-execution-contract', $executionContract) } $lines = @( Invoke-HBPythonCommand -Arguments @( $fleetEngine, 'canonical-repositories', '--manifest', $ManifestPath, '--home-dir', $HomeDir, '--existing-only' + $executionArguments ) ) if ($LASTEXITCODE -ne 0) { @@ -1280,6 +1297,17 @@ function Get-HBManagedDirtyPaths { function Initialize-HBResumeState { if (-not (Test-Path -LiteralPath $resumeEvidenceFile -PathType Leaf)) { return } $evidence = Get-Content -LiteralPath $resumeEvidenceFile -Raw -Encoding UTF8 | ConvertFrom-Json + if ($containerExecution) { + $contextContract = Get-Content -LiteralPath $executionContract -Raw -Encoding UTF8 | ConvertFrom-Json + foreach ($file in @($evidence.files)) { + $relative = ([string]$file.path).Replace('\', '/') + foreach ($mount in @($contextContract.mounts)) { + if ($relative.StartsWith("$($mount.host)/", [StringComparison]::OrdinalIgnoreCase)) { + throw 'Container-Resume-Evidence benötigt separaten Review; kein Host-Zugriff / container resume evidence requires separate review; no host access.' + } + } + } + } $dirtyPaths = @(Get-HBManagedDirtyPaths) if ($evidence.status -eq 'Applied') { if ($dirtyPaths.Count -eq 0) { @@ -1358,7 +1386,35 @@ function Get-HBPropagationPlan { return @($changes | Sort-Object Path) } +function Invoke-HBContainerPhase { + param([Parameter(Mandatory)][string] $Phase) + if (-not $containerExecution) { return $true } + $arguments = @((Join-Path $sourceRoot 'scripts/lib/maintenance_execution_context.py'), + '--contract', $executionContract, '--manifest', $ManifestPath, + '--home-dir', $HomeDir, '--source', $sourceRoot, '--registry', $registry, + '--report', $reportFile, '--mode', $maintenanceMode.FleetMode, '--phase', $Phase, + '--cleanup-profile', $CleanupProfile.ToLowerInvariant()) + if ($RepairDrift) { $arguments += '--repair-drift' } + if ($ConfirmDeepCleanup) { $arguments += '--confirm-deep-cleanup' } + if ($Phase -eq 'host-registry') { + $script:domainRegistry = Join-Path $reportDir "host-registry-${runId}.json" + $arguments += @('--output', $script:domainRegistry) + } + Invoke-HBPythonCommand -Arguments $arguments | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -eq 3) { + $script:RepairApplied = $true + return $true + } + if ($LASTEXITCODE -ne 0) { + $script:Findings++ + Write-Warning "Container-Phase blockiert / container phase blocked: ${Phase}" + return $false + } + return $true +} + function Test-HBRegistry { + if (-not (Invoke-HBContainerPhase -Phase registry)) { return } $registerScript = Join-Path $sourceRoot 'scripts/register-level2-repository.ps1' if (-not (Test-Path -LiteralPath $registerScript -PathType Leaf)) { throw "Registry-Skript fehlt / missing: ${registerScript}" @@ -1403,18 +1459,19 @@ function Test-HBRegistry { function Invoke-HBPropagationCheck { $propagation = Join-Path $sourceRoot 'scripts/propagate-agentic-toolchain-maintenance.ps1' - & $propagation -HomeDir $HomeDir -Registry $registry -CheckOnly + & $propagation -HomeDir $HomeDir -Registry $domainRegistry -CheckOnly return $LASTEXITCODE } function Invoke-HBPropagation { + if (-not (Invoke-HBContainerPhase -Phase propagation)) { return } $propagation = Join-Path $sourceRoot 'scripts/propagate-agentic-toolchain-maintenance.ps1' if (-not (Test-Path -LiteralPath $propagation -PathType Leaf)) { throw "Propagationsskript fehlt / missing: ${propagation}" } if ($WhatIfPreference) { - $previewOutput = @(& $propagation -HomeDir $HomeDir -Registry $registry -DryRun 6>&1) + $previewOutput = @(& $propagation -HomeDir $HomeDir -Registry $domainRegistry -DryRun 6>&1) $previewStatus = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE } $previewOutput | ForEach-Object { Write-Host "$_" } if ($previewStatus -ne 0) { @@ -1434,7 +1491,7 @@ function Invoke-HBPropagation { $script:Findings++ return } - & $propagation -HomeDir $HomeDir -Registry $registry -DryRun + & $propagation -HomeDir $HomeDir -Registry $domainRegistry -DryRun if ($LASTEXITCODE -ne 0) { throw 'Propagation-Vorschau fehlgeschlagen / preview failed.' } $plannedChanges = @(Get-HBPropagationPlan) if ($plannedChanges.Count -eq 0) { @@ -1449,7 +1506,7 @@ function Invoke-HBPropagation { $null = Write-HBResumeEvidence -Path $resumeEvidenceFile -RunId $runId ` -Phase 'propagation' -Files $evidenceChanges -Status Prepared ` -NextAction 'Propagation ausfuehren und Hashes verifizieren / execute propagation and verify hashes' - & $propagation -HomeDir $HomeDir -Registry $registry + & $propagation -HomeDir $HomeDir -Registry $domainRegistry if ($LASTEXITCODE -ne 0) { throw 'Propagation fehlgeschlagen / failed.' } foreach ($change in $plannedChanges) { $current = Get-HBFileSha256 -Path (Join-Path $HomeDir $change.Path) @@ -1488,14 +1545,23 @@ function Get-HBPresetConfig { } function Get-HBPresetTargets { - if (-not (Test-Path -LiteralPath $registry -PathType Leaf)) { return @() } - $data = Get-Content -LiteralPath $registry -Raw | ConvertFrom-Json + if (-not (Test-Path -LiteralPath $domainRegistry -PathType Leaf)) { return @() } + $data = Get-Content -LiteralPath $domainRegistry -Raw | ConvertFrom-Json $defaultProfile = if (($data.PSObject.Properties.Name -contains 'defaultPresetProfile') -and $data.defaultPresetProfile) { [string]$data.defaultPresetProfile } else { 'standard-eight-governance-presets' } - $targets = @([pscustomobject]@{ Level = 0; Path = $sourceRoot; Profile = $defaultProfile }) + # Keep an explicit Level-0 opt-in separate from the fleet-wide default. + $level0Profile = if ($data.PSObject.Properties.Name -contains 'level0PresetProfile') { + [string]$data.level0PresetProfile + } else { + $defaultProfile + } + if ([string]::IsNullOrWhiteSpace($level0Profile)) { + throw 'Ungueltiges Level-0-Profil / invalid Level-0 preset profile.' + } + $targets = @([pscustomobject]@{ Level = 0; Path = $sourceRoot; Profile = $level0Profile }) foreach ($entry in @($data.repositories)) { if (-not $entry.path -or $entry.level -notin @(1, 2)) { throw 'Ungueltiger Registry-Eintrag / invalid registry entry.' } $path = [IO.Path]::GetFullPath((Join-Path $HomeDir ([string]$entry.path))) @@ -1620,6 +1686,7 @@ function Invoke-HBPresetInstallerProcess { } function Invoke-HBPresetProfiles { + if (-not (Invoke-HBContainerPhase -Phase preset-profiles)) { return } $installer = Join-Path $sourceRoot 'scripts/install-spec-kit-governance-presets.ps1' if (-not (Test-Path -LiteralPath $installer -PathType Leaf)) { throw "Preset-Installer fehlt / missing: ${installer}" @@ -1705,6 +1772,9 @@ try { -DetailsJson (@{ mode = $maintenanceMode.FleetMode } | ConvertTo-Json -Compress) try { + $required = @(Invoke-HBPythonCommand -Arguments @($fleetEngine, 'execution-required', '--manifest', $ManifestPath, '--contract', $executionContract)) + if ($LASTEXITCODE -ne 0) { throw 'Invalid container execution contract.' } + $containerExecution = ($required -join '').Trim() -eq '1' Initialize-HBResumeState } catch { Write-HBEarlyFailureReport -Status BLOCKED -ExitCode 1 ` @@ -1813,6 +1883,7 @@ try { $registrySafe = $registryStatus -eq 0 if (-not $registrySafe) { $script:Findings++ } } + if (-not (Invoke-HBContainerPhase -Phase host-registry)) { $registrySafe = $false } if ($script:Findings -gt $findingsBefore -or -not $registrySafe) { Add-HBReportStage -StageId 'registry' -Status Blocked -ExitCode 1 ` -Summary 'Registry-Pruefung mit Befund / registry check has findings' ` @@ -1932,9 +2003,10 @@ try { $CleanupProfile -ne 'None') { Start-HBMaintenanceEventPhase -PhaseId 'storage-cleanup' Write-HBInfo 'Workspace-Speicher pflegen / Maintain workspace storage' + $null = Invoke-HBContainerPhase -Phase storage-cleanup $storageParameters = @{ HomeDir = $HomeDir - RegistryPath = $registry + RegistryPath = $domainRegistry PolicyPath = $storagePolicy ResultFile = $storageResultFile RunId = $runId diff --git a/scripts/maintain-agentic-workspace.sh b/scripts/maintain-agentic-workspace.sh index 30d69f10..065ea4f5 100755 --- a/scripts/maintain-agentic-workspace.sh +++ b/scripts/maintain-agentic-workspace.sh @@ -738,6 +738,9 @@ esac mkdir -p -- "$HOME_DIR" HOME_DIR="$(cd -- "$HOME_DIR" && pwd -P)" REGISTRY="${HOME_DIR}/.home-baseline/level2-repository-registry.json" +EXECUTION_CONTRACT="${SOURCE_ROOT}/scripts/config/maintenance-execution-contexts.json" +CONTAINER_EXECUTION=0 +DOMAIN_REGISTRY="$REGISTRY" # A stale copy in ~/scripts must delegate before it updates that directory. if [ "$SCRIPT_DIR" = "${HOME_DIR}/scripts" ]; then @@ -764,6 +767,12 @@ if [ "$UI_MODE" = "plain" ]; then exit $? fi +# Erst der Engine-Lauf braucht den Flottenvertrag; UI-Abbruch und Home- +# Delegation muessen ohne lokale Level-0-Manifeste funktionieren. +# Only engine execution needs the fleet contract; UI cancellation and Home +# delegation must work without a local copy of Level-0 manifests. +CONTAINER_EXECUTION="$(python3 "$FLEET_ENGINE" execution-required --manifest "$FLEET_MANIFEST" --contract "$EXECUTION_CONTRACT")" + [ -f "$STORAGE_MAINTAINER" ] || die "Storage-Wartung fehlt / storage maintainer missing: $STORAGE_MAINTAINER" [ -f "$STORAGE_POLICY" ] || die "Storage-Policy fehlt / storage policy missing: $STORAGE_POLICY" @@ -851,6 +860,8 @@ record_stage() { run_fleet_contract() { local fleet_mode="update" status=0 + local -a execution_arguments=() + [ "$CONTAINER_EXECUTION" -eq 1 ] && execution_arguments+=(--execution-contract "$EXECUTION_CONTRACT") [ "$CHECK_ONLY" -eq 1 ] && fleet_mode="check-only" [ "$DRY_RUN" -eq 1 ] && fleet_mode="dry-run" python3 "$FLEET_ENGINE" fleet \ @@ -860,7 +871,7 @@ run_fleet_contract() { --report "$REPORT_FILE" \ --log "$LOG_FILE" \ --run-id "$RUN_ID" \ - --level0-dir "$SOURCE_ROOT" || status=$? + --level0-dir "$SOURCE_ROOT" ${execution_arguments[@]+"${execution_arguments[@]}"} || status=$? return "$status" } @@ -930,16 +941,49 @@ check_repository() { } discover_repositories() { + local -a execution_arguments=() + [ "$CONTAINER_EXECUTION" -eq 1 ] && execution_arguments+=(--exclude-execution-contract "$EXECUTION_CONTRACT") python3 "$FLEET_ENGINE" canonical-repositories \ --manifest "$FLEET_MANIFEST" \ --home-dir "$HOME_DIR" \ - --existing-only + --existing-only ${execution_arguments[@]+"${execution_arguments[@]}"} +} + +run_container_phase() { + [ "$CONTAINER_EXECUTION" -eq 1 ] || return 0 + local phase="$1" execution_mode="update" status=0 + local -a arguments + [ "$CHECK_ONLY" -eq 1 ] && execution_mode="check-only" + [ "$DRY_RUN" -eq 1 ] && execution_mode="dry-run" + arguments=(python3 "${SOURCE_ROOT}/scripts/lib/maintenance_execution_context.py" + --contract "$EXECUTION_CONTRACT" --manifest "$FLEET_MANIFEST" + --home-dir "$HOME_DIR" --source "$SOURCE_ROOT" --registry "$REGISTRY" + --report "$REPORT_FILE" --mode "$execution_mode" --phase "$phase" --cleanup-profile "$CLEANUP_PROFILE") + [ "$REPAIR_DRIFT" -eq 1 ] && arguments+=(--repair-drift) + [ "$CONFIRM_DEEP_CLEANUP" -eq 1 ] && arguments+=(--confirm-deep-cleanup) + if [ "$phase" = "host-registry" ]; then + DOMAIN_REGISTRY="${REPORT_DIR}/host-registry-${RUN_ID}.json" + arguments+=(--output "$DOMAIN_REGISTRY") + fi + "${arguments[@]}" || status=$? + if [ "$status" -eq 3 ]; then + REPAIR_APPLIED=1 + return 0 + fi + if [ "$status" -ne 0 ]; then + FINDINGS=$((FINDINGS + 1)) + [ "$status" -ge 2 ] && OPERATIONAL_FAILURE=1 + warn "Container-Phase blockiert / container phase blocked: ${phase}" + return 1 + fi + return 0 } ensure_registry() { local register_script="${SOURCE_ROOT}/scripts/register-level2-repository.sh" local level repo preview_output registry_drift=0 [ -f "$register_script" ] || die "Registry-Skript fehlt / missing: $register_script" + run_container_phase registry || return 1 while IFS=$'\t' read -r level repo; do [ -n "$repo" ] || continue @@ -966,16 +1010,17 @@ ensure_registry() { run_propagation_check() { local propagation="${SOURCE_ROOT}/scripts/propagate-agentic-toolchain-maintenance.sh" - HOME="$HOME_DIR" bash "$propagation" --home-dir "$HOME_DIR" --registry "$REGISTRY" --check-only + HOME="$HOME_DIR" bash "$propagation" --home-dir "$HOME_DIR" --registry "$DOMAIN_REGISTRY" --check-only } handle_propagation() { local propagation="${SOURCE_ROOT}/scripts/propagate-agentic-toolchain-maintenance.sh" local status=0 preview_output="" [ -f "$propagation" ] || die "Propagationsskript fehlt / missing: $propagation" + run_container_phase propagation || return 0 if [ "$DRY_RUN" -eq 1 ]; then - preview_output="$(HOME="$HOME_DIR" bash "$propagation" --home-dir "$HOME_DIR" --registry "$REGISTRY" --dry-run)" || status=$? + preview_output="$(HOME="$HOME_DIR" bash "$propagation" --home-dir "$HOME_DIR" --registry "$DOMAIN_REGISTRY" --dry-run)" || status=$? printf '%s\n' "$preview_output" if [ "$status" -ne 0 ]; then FINDINGS=$((FINDINGS + 1)) @@ -994,8 +1039,8 @@ handle_propagation() { FINDINGS=$((FINDINGS + 1)) return fi - HOME="$HOME_DIR" bash "$propagation" --home-dir "$HOME_DIR" --registry "$REGISTRY" --dry-run - HOME="$HOME_DIR" bash "$propagation" --home-dir "$HOME_DIR" --registry "$REGISTRY" + HOME="$HOME_DIR" bash "$propagation" --home-dir "$HOME_DIR" --registry "$DOMAIN_REGISTRY" --dry-run + HOME="$HOME_DIR" bash "$propagation" --home-dir "$HOME_DIR" --registry "$DOMAIN_REGISTRY" run_propagation_check REPAIR_APPLIED=1 ;; @@ -1012,7 +1057,7 @@ preset_config_for_profile() { } discover_preset_targets() { - python3 - "$HOME_DIR" "$REGISTRY" "$SOURCE_ROOT" <<'PY' + python3 - "$HOME_DIR" "$DOMAIN_REGISTRY" "$SOURCE_ROOT" <<'PY' import json import pathlib import sys @@ -1022,7 +1067,11 @@ registry_path = pathlib.Path(sys.argv[2]) source = pathlib.Path(sys.argv[3]).resolve() data = json.loads(registry_path.read_text(encoding="utf-8")) default_profile = data.get("defaultPresetProfile", "standard-eight-governance-presets") -print(f"0\t{source}\t{default_profile}") +# A reviewed Level-0 opt-in must not silently raise the fleet-wide default. +level0_profile = data.get("level0PresetProfile", default_profile) +if not isinstance(level0_profile, str) or not level0_profile.strip(): + raise SystemExit("Invalid Level-0 preset profile") +print(f"0\t{source}\t{level0_profile}") for entry in data.get("repositories", []): raw = entry.get("path") profile = entry.get("presetProfile", default_profile) @@ -1106,6 +1155,7 @@ handle_preset_profiles() { local level repo profile config status target isolated [ -f "$installer" ] || die "Preset-Installer fehlt / missing: $installer" [ -f "$PRESET_PROFILE_CATALOG" ] || die "Preset-Profilkatalog fehlt / missing: $PRESET_PROFILE_CATALOG" + run_container_phase preset-profiles || return 0 while IFS=$'\t' read -r level repo profile; do [ -n "$repo" ] || continue @@ -1231,6 +1281,9 @@ if [ -f "$REGISTRY" ]; then [ "$registry_status" -eq 0 ] && registry_safe=1 [ "$registry_status" -eq 0 ] || FINDINGS=$((FINDINGS + 1)) fi +if ! run_container_phase host-registry; then + registry_safe=0 +fi if [ "$FINDINGS" -gt "$findings_before" ] || [ "$registry_safe" -ne 1 ]; then record_stage "registry" "Blocked" 1 "Registry-Pruefung mit Befund / registry check has findings" \ "Registry-Befund beheben / resolve registry finding" @@ -1379,10 +1432,11 @@ if [ "$registry_safe" -eq 1 ] \ "Storage-Inventur gestartet." "Storage inventory started." info "Workspace-Speicher pflegen / Maintain workspace storage" storage_status=0 + run_container_phase storage-cleanup || true storage_arguments=( bash "$STORAGE_MAINTAINER" --home-dir "$HOME_DIR" - --registry "$REGISTRY" + --registry "$DOMAIN_REGISTRY" --policy "$STORAGE_POLICY" --run-id "$RUN_ID" --profile "$CLEANUP_PROFILE" diff --git a/scripts/tests/test_maintenance_execution_context.py b/scripts/tests/test_maintenance_execution_context.py new file mode 100644 index 00000000..3092ff56 --- /dev/null +++ b/scripts/tests/test_maintenance_execution_context.py @@ -0,0 +1,346 @@ +"""Sandbox routing fixtures: no production paths, network or Podman required.""" +import datetime +import importlib.util +import json +import os +import sys +from pathlib import Path +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +MODULE = Path(__file__).resolve().parents[1] / "lib/maintenance_execution_context.py" +spec = importlib.util.spec_from_file_location("maintenance_execution_context", MODULE) +context = importlib.util.module_from_spec(spec) +spec.loader.exec_module(context) +worker_spec = importlib.util.spec_from_file_location("maintenance_container_worker", MODULE.with_name("maintenance_container_worker.py")) +worker = importlib.util.module_from_spec(worker_spec) +worker_spec.loader.exec_module(worker) +fleet_spec = importlib.util.spec_from_file_location("delegation_test_fleet", MODULE.with_name("agentic_workspace_fleet.py")) +fleet = importlib.util.module_from_spec(fleet_spec) +fleet_spec.loader.exec_module(fleet) + + +def contract(): + return {"schemaVersion": 1, "container": "test-sandbox", "user": "tester", + "source": "/opt/reference", "approvalPath": "sandbox/approval.md", + "expiresOn": "2099-12-31", "mounts": [{"host": "Projects", "container": "/projects"}]} + + +class ExecutionContextTests(unittest.TestCase): + def test_strict_contract_and_expiry(self): + self.assertEqual(context.validate_contract(contract()), contract()) + for changes in ({"schemaVersion": True}, {"schemaVersion": 2}, {"expiresOn": "2000-01-01"}, + {"source": "/opt/../root"}, {"approvalPath": "../secret"}, {"unknown": True}, + {"container": "--latest"}): + with self.subTest(changes=changes), self.assertRaises(context.ExecutionContextError): + context.validate_contract({**contract(), **changes}) + + def test_paths_reject_escape_and_ambiguity(self): + for value in ("", ".", "../escape", "/root", "a//b", "a/./b", "a\\b", 1): + with self.subTest(value=value), self.assertRaises(context.ExecutionContextError): + context.relative_path(value) + self.assertEqual(str(context.relative_path("path with spaces/repo")), "path with spaces/repo") + + def make_context(self, root): + path = root / "contract.json" + path.write_text(json.dumps(contract())) + return context.ExecutionContexts(path, root, [ + {"path": "Projects", "active": True, "kind": "git-repository"}, + {"path": "Projects/repo with spaces", "active": True, "kind": "git-repository"}]) + + def test_exact_mapping_not_sibling_prefix(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + router = self.make_context(root) + self.assertEqual(router.mapping(root / "Projects/repo with spaces"), + ("/projects", "/projects/repo with spaces")) + self.assertIsNone(router.mapping(root / "Projects-other/repo")) + with self.assertRaises(context.ExecutionContextError): + router.mapping(root / "Projects/not-declared") + + def test_missing_sandbox_never_falls_back(self): + with tempfile.TemporaryDirectory() as directory: + router = self.make_context(Path(directory)) + with patch.object(context.subprocess, "run", side_effect=FileNotFoundError), \ + self.assertRaises(context.ExecutionContextError): + router.inspect() + + def test_stopped_wrong_user_wrong_mount_and_changed_identity(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + router = self.make_context(root) + valid = {"Id": "abc", "Image": "def", "State": {"Running": True}, + "Config": {"User": "tester"}, "Mounts": [ + {"Destination": "/projects", "Source": str(root / "Projects"), "Type": "bind", "RW": True}]} + for changes in ({"State": {"Running": False}}, {"Config": {"User": "root"}}, {"Mounts": []}): + response = subprocess.CompletedProcess([], 0, json.dumps([{**valid, **changes}]), "") + with patch.object(context.subprocess, "run", return_value=response), self.assertRaises(context.ExecutionContextError): + router.inspect() + router.container_id, router.image = "different", "def" + with patch.object(context.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, json.dumps([valid]), "")), self.assertRaises(context.ExecutionContextError): + router.inspect() + + def test_remote_probe_and_symlink_rejection(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + repo = root / "repo with spaces" + repo.mkdir() + (repo / ".git").mkdir() + payload = {"root": str(root), "repository": str(repo), "action": "probe"} + result = subprocess.run(["python3", "-c", context.REMOTE_PROGRAM], input=json.dumps(payload), text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(json.loads(result.stdout)["git"]) + alias = root / "alias" + alias.symlink_to(repo, target_is_directory=True) + payload["repository"] = str(alias) + result = subprocess.run(["python3", "-c", context.REMOTE_PROGRAM], input=json.dumps(payload), text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + + def test_binding_rejects_missing_or_symlinked_package_files(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + config = root / "scripts/config" + config.mkdir(parents=True) + manifest = config / "agentic-toolchain-maintenance-files.json" + manifest.write_text(json.dumps({"files": [{"path": "missing"}]})) + with self.assertRaises(context.ExecutionContextError): + context.source_bindings(root) + manifest.write_text(json.dumps({"files": [{"path": "../escape"}]})) + with self.assertRaises(context.ExecutionContextError): + context.source_bindings(root) + + def test_real_package_binds_worker_and_contract(self): + bindings = context.source_bindings(MODULE.parents[2]) + self.assertIn("scripts/lib/maintenance_container_worker.py", bindings) + self.assertIn("scripts/config/maintenance-execution-contexts.json", bindings) + self.assertTrue(all(len(value) == 64 for value in bindings.values())) + + def test_expired_contract_can_be_routed_but_not_executed(self): + expired = {**contract(), "expiresOn": "2000-01-01"} + self.assertEqual(context.validate_contract(expired, check_expiry=False), expired) + with self.assertRaises(context.ExecutionContextError): + context.validate_contract(expired) + + def test_remote_program_rejects_delivery_and_unbounded_merges(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + repo = root / "repo" + repo.mkdir() + (repo / ".git").mkdir() + for args in (["push"], ["commit"], ["merge", "main"], ["merge", "--ff-only", "origin/main"]): + payload = {"root": str(root), "repository": str(repo), "action": "git", "arguments": args} + result = subprocess.run(["python3", "-c", context.REMOTE_PROGRAM], input=json.dumps(payload), text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0, args) + + def worker_payload(self, root, phase="propagation", mode="check-only"): + repo = root / "repo with spaces" + repo.mkdir() + (repo / ".git").mkdir() + return {"source": str(root), "mode": mode, "phase": phase, "runId": "fixture", + "repairDrift": False, "cleanupProfile": "safe", "defaultPresetProfile": "none", + "entries": [{"containerRoot": str(root), "containerPath": str(repo), "targetId": "fixture", + "registry": {"path": "Projects/repo with spaces", "level": 2}}]} + + def test_worker_never_repairs_without_explicit_flag(self): + with tempfile.TemporaryDirectory() as directory: + payload = self.worker_payload(Path(directory).resolve(), mode="update") + with patch.object(worker.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, "drift", "")) as run: + result = worker.execute(payload) + self.assertEqual(result["status"], "Blocked") + self.assertEqual(run.call_count, 1) + self.assertEqual(run.call_args.args[0][-1], "--check-only") + self.assertIn(payload["entries"][0]["containerPath"], run.call_args.args[0]) + + def test_worker_preview_preserves_drift_status(self): + with tempfile.TemporaryDirectory() as directory: + payload = self.worker_payload(Path(directory).resolve(), mode="dry-run") + with patch.object(worker.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, "drift", "")): + result = worker.execute(payload) + self.assertEqual(result["status"], "Blocked") + + def test_worker_registry_preview_does_not_claim_update_success(self): + with tempfile.TemporaryDirectory() as directory: + payload = self.worker_payload(Path(directory).resolve(), phase="registry") + with patch.object(worker.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "updated: fixture\n", "")): + result = worker.execute(payload) + self.assertEqual(result["status"], "Blocked") + self.assertEqual(result["registry"][0]["path"], payload["entries"][0]["containerPath"]) + + def test_worker_refuses_dirty_repair(self): + with tempfile.TemporaryDirectory() as directory: + payload = self.worker_payload(Path(directory).resolve(), mode="update") + payload["repairDrift"] = True + results = [subprocess.CompletedProcess([], 1, "drift", ""), + subprocess.CompletedProcess([], 0, " M user-file\n", "")] + with patch.object(worker.subprocess, "run", side_effect=results) as run: + result = worker.execute(payload) + self.assertEqual(run.call_count, 2) + self.assertEqual(result["records"][0]["reason"], "DirtyWorktreeRequiresReview") + self.assertEqual(result["status"], "Blocked") + + def test_worker_refuses_symlink_git_directory(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + payload = self.worker_payload(root) + gitdir = Path(payload["entries"][0]["containerPath"]) / ".git" + gitdir.rmdir() + gitdir.symlink_to(root, target_is_directory=True) + with patch.object(worker.subprocess, "run") as run, self.assertRaises(ValueError): + worker.execute(payload) + run.assert_not_called() + + @unittest.skipIf(os.name == "nt", "Linux leaf worker; native Windows wrapper is tested separately") + def test_real_registry_worker_preserves_curated_metadata(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + payload = self.worker_payload(root, phase="registry", mode="update") + payload["source"] = str(MODULE.parents[2]) + entry = payload["entries"][0] + subprocess.run(["git", "init", "-q", entry["containerPath"]], check=True) + entry["registry"].update(primaryLanguage="csharp", mslStatus="msl", gsdbRequired=True, + presetProfile="none", role="level-2-project", source="owner-curated", + registeredAt="2026-01-01") + result = worker.execute(payload) + self.assertEqual(result["status"], "Passed", result) + returned = result["registry"][0] + for key in ("primaryLanguage", "mslStatus", "gsdbRequired", "presetProfile", "registeredAt", "source"): + self.assertEqual(returned[key], entry["registry"][key]) + + @unittest.skipIf(os.name == "nt", "Linux leaf worker; native Windows wrapper is tested separately") + def test_real_propagation_worker_reports_drift_without_writes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + payload = self.worker_payload(root, mode="check-only") + payload["source"] = str(MODULE.parents[2]) + repo = Path(payload["entries"][0]["containerPath"]) + subprocess.run(["git", "init", "-q", str(repo)], check=True) + (repo / "AGENTS.md").write_text("# Fixture\n", encoding="utf-8") + before = sorted(p.relative_to(repo).as_posix() for p in repo.rglob("*")) + result = worker.execute(payload) + self.assertEqual(result["status"], "Blocked", result) + self.assertEqual(before, sorted(p.relative_to(repo).as_posix() for p in repo.rglob("*"))) + + def test_preflight_failure_closes_barrier_before_git(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + args = fleet.build_parser().parse_args(["fleet", "--manifest", str(MODULE.parents[2] / "scripts/config/agentic-workspace-fleet.json"), + "--home-dir", str(root), "--mode", "update", "--report", str(root / "report.json"), + "--log", str(root / "run.log"), "--execution-contract", str(MODULE.parents[2] / "scripts/config/maintenance-execution-contexts.json")]) + with patch.dict(sys.modules, {"maintenance_execution_context": context}), \ + patch.object(context.ExecutionContexts, "preflight", side_effect=context.ExecutionContextError("fixture unavailable")), \ + patch.object(fleet, "run_git_network") as network: + self.assertEqual(fleet.execute_fleet(args), 2) + network.assert_not_called() + report = json.loads(args.report.read_text()) + self.assertFalse(report["mutationBarrier"]["domainMutationAllowed"]) + self.assertEqual(report["findings"][0]["code"], "SandboxPreflightBlocked") + + def test_two_delegated_fast_forwards_follow_all_fetches(self): + fixture_spec = importlib.util.spec_from_file_location("fleet_fixtures", Path(__file__).with_name("test_agentic_workspace_maintenance.py")) + fixtures = importlib.util.module_from_spec(fixture_spec) + fixture_spec.loader.exec_module(fixtures) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + fixture = fixtures.FleetFixture(root) + fixture.run("update") + second = fixture.home / "Fleet/Second" + second_remote = root / "second.git" + subprocess.run(["git", "clone", "--bare", "-q", str(fixture.remote), str(second_remote)], check=True) + subprocess.run(["git", "clone", "-q", str(second_remote), str(second)], check=True) + manifest_path = fixture.manifest() + manifest = json.loads(manifest_path.read_text()) + manifest["targets"] = [manifest["targets"][0], manifest["targets"][1], {**manifest["targets"][1], "id": "second", "path": "Fleet/Second", "remote": str(second_remote)}] + manifest_path.write_text(json.dumps(manifest)) + (fixture.seed / "README.md").write_text("remote update\n") + fixtures.git(fixture.seed, "commit", "-qam", "advance") + fixtures.git(fixture.seed, "push", "-q") + fixtures.git(fixture.seed, "push", "-q", str(second_remote), "main") + expected = fixtures.git(fixture.seed, "rev-parse", "HEAD") + contract_path = root / "contract.json" + contract_path.write_text(json.dumps({**contract(), "mounts": [{"host": "Fleet", "container": "/fleet"}]})) + args = fleet.build_parser().parse_args(["fleet", "--manifest", str(manifest_path), "--home-dir", str(fixture.home), + "--mode", "update", "--report", str(root / "delegated.json"), "--log", str(root / "run.log"), + "--execution-contract", str(contract_path)]) + operations = [] + original_network = fleet.run_git_network + def network(repository, *arguments): + operations.append(arguments[0]) + return original_network(repository, *arguments) + def request(router, repository, action, arguments=()): + if arguments and arguments[0] == "merge": + self.assertEqual(operations.count("fetch"), 2) + operations.append("local-ff") + result = subprocess.run([sys.executable, "-c", context.REMOTE_PROGRAM], + input=json.dumps({"root": str(fixture.home / "Fleet"), "repository": str(repository), + "action": action, "arguments": arguments}), text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + with patch.dict(sys.modules, {"maintenance_execution_context": context}), \ + patch.object(context.ExecutionContexts, "preflight", return_value={"context": "container"}), \ + patch.object(context.ExecutionContexts, "request", request), \ + patch.object(fleet, "run_git_network", network): + self.assertEqual(fleet.execute_fleet(args), 0) + self.assertEqual(operations.count("local-ff"), 2) + report = json.loads(args.report.read_text()) + self.assertTrue(report["mutationBarrier"]["domainMutationAllowed"]) + for name in ("Example", "Second"): + self.assertEqual(fixtures.git(fixture.home / "Fleet" / name, "rev-parse", "HEAD"), expected) + fleet.EXECUTION_CONTEXTS = None + + def phase_fixture(self, root, mode="update"): + router = self.make_context(root) + contract_path = root / "contract.json" + targets = [{"id": "fixture", "path": "Projects/repo with spaces", "active": True, "kind": "git-repository"}] + manifest = root / "manifest.json" + manifest.write_text(json.dumps({"targets": targets})) + registry = root / "registry.json" + registry.write_text(json.dumps({"repositories": [{"path": targets[0]["path"], "level": 2}, {"path": "Other/repo", "level": 2}]})) + evidence = {"context": "container", "containerId": "fixture", "image": "fixture-image", "approvalSha256": "a", "sourceSha256": "b"} + report = root / "report.json" + report.write_text(json.dumps({"runId": "fixture", "executionContexts": [evidence], "mutationBarrier": {"domainMutationAllowed": True}})) + args = SimpleNamespace(contract=contract_path, manifest=manifest, home_dir=root, source=MODULE.parents[2], + registry=registry, report=report, phase="registry", mode=mode, repair_drift=False, cleanup_profile="none", + confirm_deep_cleanup=False, output=root / "host-registry.json") + return args, evidence + + def test_registry_return_is_translated_and_host_entries_are_preserved(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + args, evidence = self.phase_fixture(root) + response = {"status": "Passed", "records": [{"targetId": "fixture", "status": "Passed"}], + "registry": [{"path": "/projects/repo with spaces", "level": 2, "primaryLanguage": "csharp"}]} + with patch.object(context.ExecutionContexts, "preflight", return_value=evidence), \ + patch.object(context.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, json.dumps(response), "")), \ + patch.dict(sys.modules, {"agentic_workspace_fleet": fleet}): + self.assertEqual(context.run_phase(args), 0) + entries = json.loads(args.registry.read_text())["repositories"] + self.assertEqual(entries[0]["path"], "Projects/repo with spaces") + self.assertEqual(entries[0]["primaryLanguage"], "csharp") + self.assertEqual(entries[1], {"path": "Other/repo", "level": 2}) + self.assertEqual(json.loads(args.report.read_text())["delegatedPhases"][0]["executionContext"], "container") + + def test_lost_container_does_not_modify_registry_or_report(self): + with tempfile.TemporaryDirectory() as directory: + args, evidence = self.phase_fixture(Path(directory).resolve()) + before = (args.registry.read_bytes(), args.report.read_bytes()) + with patch.object(context.ExecutionContexts, "preflight", return_value=evidence), \ + patch.object(context.subprocess, "run", return_value=subprocess.CompletedProcess([], 125, "", "lost")), \ + self.assertRaises(context.ExecutionContextError): + context.run_phase(args) + self.assertEqual(before, (args.registry.read_bytes(), args.report.read_bytes())) + + def test_host_registry_filter_excludes_delegated_targets(self): + with tempfile.TemporaryDirectory() as directory: + args, evidence = self.phase_fixture(Path(directory).resolve(), mode="check-only") + args.phase = "host-registry" + with patch.object(context.ExecutionContexts, "preflight", return_value=evidence), patch.object(context.subprocess, "run") as run: + self.assertEqual(context.run_phase(args), 0) + run.assert_not_called() + self.assertEqual(json.loads(args.output.read_text())["repositories"], [{"path": "Other/repo", "level": 2}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_maintenance_tui_wrappers.py b/scripts/tests/test_maintenance_tui_wrappers.py index d0d70c70..aba5d2bb 100644 --- a/scripts/tests/test_maintenance_tui_wrappers.py +++ b/scripts/tests/test_maintenance_tui_wrappers.py @@ -9,6 +9,7 @@ import shlex import shutil import subprocess +import sys import tempfile import unittest import uuid @@ -71,6 +72,80 @@ def test_bash_emit_event_persists_object_details_without_sequence_gap(self) -> N self.assertEqual(events[-1]["eventType"], "run-completed") self.assertEqual(events[-1]["details"]["logPath"], "run.log") + def test_event_cli_accepts_every_runtime_phase(self) -> None: + phases = ["fleet", "level0", "home-sync", "registry", "propagation", + "preset-profiles", "toolchain", "model-routing", "storage-cleanup", "final"] + with tempfile.TemporaryDirectory() as directory: + stream = Path(directory) / "events.jsonl" + run_id = str(uuid.uuid4()) + for sequence, phase in enumerate(phases, 1): + result = subprocess.run([ + sys.executable, str(FLEET_ENGINE), "event", "--event-stream", str(stream), + "--run-id", run_id, "--sequence", str(sequence), "--event-type", "phase-completed", + "--status", "PASSED", "--phase-id", phase, + "--message-de", "Geprueft.", "--message-en", "Checked.", + "--details-json", '{"exitCode":0}', + ], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + events = [json.loads(line) for line in stream.read_text().splitlines()] + self.assertEqual([event["phaseId"] for event in events], phases) + self.assertEqual([event["sequence"] for event in events], list(range(1, len(phases) + 1))) + + @unittest.skipUnless( + (REPOSITORY / "specs/018-agentic-workspace-tui/contracts/maintenance-event-v1.schema.json").is_file(), + "Schema/reader parity is a canonical Level-0 check, not a distributed package dependency.", + ) + def test_canonical_schema_includes_storage_and_routing(self) -> None: + schema = json.loads((REPOSITORY / "specs/018-agentic-workspace-tui/contracts/maintenance-event-v1.schema.json").read_text()) + reader = (REPOSITORY / "scripts/lib/maintenance-tui/src/HomeBaseline.MaintenanceTui/Contracts/MaintenanceEvent.cs").read_text() + for phase in ("model-routing", "storage-cleanup"): + self.assertIn(phase, schema["properties"]["phaseId"]["enum"]) + self.assertIn(f'"{phase}"', reader) + + @unittest.skipIf(os.name == "nt", "The Bash wrapper runs on Unix targets.") + def test_level0_profile_does_not_change_project_default(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "project" / ".git").mkdir(parents=True) + registry = root / "registry.json" + for override in (None, "project-statistics-fourteen-governance-presets"): + data = {"defaultPresetProfile": "model-routing-twelve-governance-presets", + "repositories": [{"path": "project", "level": 2}]} + if override: + data["level0PresetProfile"] = override + registry.write_text(json.dumps(data)) + script = "\n".join(( + "set -euo pipefail", + f"HOME_DIR={shlex.quote(str(root))}", + f"DOMAIN_REGISTRY={shlex.quote(str(registry))}", + f"SOURCE_ROOT={shlex.quote(str(REPOSITORY))}", + extract_bash_function("discover_preset_targets"), + "discover_preset_targets", + )) + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + rows = [line.split("\t") for line in result.stdout.splitlines()] + self.assertEqual(rows[0][2], override or data["defaultPresetProfile"]) + self.assertEqual(rows[1][2], data["defaultPresetProfile"]) + if shutil.which("pwsh"): + # Parse the real function without executing the maintenance entrypoint. + command = r''' +param($Wrapper, $Registry, $HomeDir, $SourceRoot) +$tokens = $null; $errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile($Wrapper, [ref]$tokens, [ref]$errors) +$function = $ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Get-HBPresetTargets'}, $true) +$domainRegistry = $Registry +. ([scriptblock]::Create($function.Extent.Text)) +Get-HBPresetTargets | ConvertTo-Json -Depth 5 -Compress +''' + fixture = root / "profile-test.ps1" + fixture.write_text(command) + ps = subprocess.run(["pwsh", "-NoProfile", "-File", str(fixture), + str(POWERSHELL_WRAPPER), str(registry), str(root), str(REPOSITORY)], + capture_output=True, text=True) + self.assertEqual(ps.returncode, 0, ps.stderr) + self.assertEqual([row["Profile"] for row in json.loads(ps.stdout)], [row[2] for row in rows]) + @unittest.skipIf(os.name == "nt", "The Bash wrapper runs on Unix targets.") def test_bash_failed_event_does_not_consume_sequence(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -297,6 +372,28 @@ def test_bash_unsupported_terminal_falls_back_and_cancels_before_engine(self) -> self.assertIn("lineare Ausgabe", result.stdout + result.stderr) self.assertIn("Cancelled before engine start", result.stdout) + @unittest.skipIf(os.name == "nt", "The Bash wrapper runs on Unix targets.") + def test_distributed_ui_cancels_without_level0_manifest_or_engine_call(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + scripts = root / "package" / "scripts" + (scripts / "lib").mkdir(parents=True) + wrapper = scripts / BASH_WRAPPER.name + shutil.copyfile(BASH_WRAPPER, wrapper) + marker = root / "engine-called" + (scripts / "lib" / FLEET_ENGINE.name).write_text( + "from pathlib import Path\n" + f"Path({str(marker)!r}).touch()\nraise SystemExit(77)\n" + ) + result = subprocess.run( + ["bash", str(wrapper), "--tui", "--home-dir", str(root / "home")], + input="3\nn\nn\nn\nn\n", text=True, capture_output=True, + env={"HOME": str(root / "home"), "PATH": "/usr/bin:/bin", "TERM": "dumb"}, + check=False, + ) + self.assertEqual(result.returncode, 130, result.stdout + result.stderr) + self.assertFalse(marker.exists(), "UI cancellation must precede engine preflight") + @unittest.skipIf(os.name == "nt", "The Bash wrapper runs on Unix targets.") def test_bash_rejects_ui_with_preselected_maintenance_mode(self) -> None: cases = ( From 6535fa522d82de510902878d455b1efb2b45b4f2 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Sun, 20 Sep 2026 11:24:17 +0200 Subject: [PATCH 2/6] docs: refresh project transparency snapshot --- docs/project-statistics/report.md | 49 ++++++++++++++------------- docs/project-statistics/snapshot.json | 2 +- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/project-statistics/report.md b/docs/project-statistics/report.md index 13c1000e..8f73da64 100644 --- a/docs/project-statistics/report.md +++ b/docs/project-statistics/report.md @@ -9,24 +9,24 @@ Git-bound inventory and activity; not a measure of quality, learning performance | Kennzahl / Metric | Wert / Value | | --- | --- | -| Textdateien / Text files | 3483 | -| Textzeilen / Text lines | 750256 | -| Aktivtage / Active days | 97 | -| Stichtag / As of | 2026-09-19 | -| Fensterbeginn / Window start | 2025-09-21 | +| Textdateien / Text files | 3487 | +| Textzeilen / Text lines | 751459 | +| Aktivtage / Active days | 98 | +| Stichtag / As of | 2026-09-20 | +| Fensterbeginn / Window start | 2025-09-28 | | Zeitzone / Time zone | UTC | Quellrevision / Source revision: -38546cea5dd264b339f17b66005ac93df7446ffd +bb9c03d597678e0eb189d9c7f9d14410169da05e ### Artefakte / Artifacts | Kategorie / Category | Dateien / Files | Zeilen / Lines | | --- | ---: | ---: | | Production | 854 | 140068 | -| Tests | 428 | 54626 | -| Documentation | 1600 | 384380 | -| Scripts | 133 | 28151 | +| Tests | 429 | 55069 | +| Documentation | 1600 | 384417 | +| Scripts | 136 | 28874 | | Configuration | 139 | 43165 | | DataMedia | 1 | 1 | | Other | 328 | 99865 | @@ -38,25 +38,25 @@ EN: Cells show gross changes per day, not hours. Exact daily values follow. 0=0; 1=1..79; 2=80..399; 3=400..1599; 4=1600+; -=zukuenftig/future ```text -Wochen / Weeks 01..26 | 2025-09-21..2026-03-21 -So/Su 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 0 4 4 0 -Mo/Mo 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 -Di/Tu 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 -Mi/We 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +Wochen / Weeks 01..26 | 2025-09-28..2026-03-28 +So/Su 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 0 4 4 0 4 +Mo/Mo 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 +Di/Tu 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 3 +Mi/We 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 4 Do/Th 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -Fr/Fr 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 4 -Sa/Sa 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 +Fr/Fr 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 4 4 +Sa/Sa 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 ``` ```text -Wochen / Weeks 27..52 | 2026-03-22..2026-09-19 -So/Su 4 4 0 2 0 0 0 4 0 0 3 0 4 0 0 0 4 4 4 1 4 0 0 4 0 4 -Mo/Mo 4 4 0 1 4 0 4 0 0 0 1 0 2 0 3 1 4 0 0 0 0 2 0 0 4 0 -Di/Tu 3 2 0 0 0 0 3 4 0 3 2 0 0 0 2 0 4 4 4 0 0 0 0 0 4 0 -Mi/We 4 0 0 0 3 0 4 1 0 0 2 0 4 0 2 0 4 2 4 0 0 0 0 0 0 0 -Do/Th 0 4 0 0 0 4 0 0 3 3 3 4 2 0 0 0 4 4 0 0 4 0 0 0 0 0 -Fr/Fr 4 0 0 2 4 0 4 1 4 3 0 0 4 2 3 4 4 4 1 0 0 0 4 0 0 0 -Sa/Sa 4 0 4 0 0 4 4 0 0 4 0 4 3 0 4 4 2 4 2 4 4 0 4 0 4 4 +Wochen / Weeks 27..52 | 2026-03-29..2026-09-26 +So/Su 4 0 2 0 0 0 4 0 0 3 0 4 0 0 0 4 4 4 1 4 0 0 4 0 4 3 +Mo/Mo 4 0 1 4 0 4 0 0 0 1 0 2 0 3 1 4 0 0 0 0 2 0 0 4 0 - +Di/Tu 2 0 0 0 0 3 4 0 3 2 0 0 0 2 0 4 4 4 0 0 0 0 0 4 0 - +Mi/We 0 0 0 3 0 4 1 0 0 2 0 4 0 2 0 4 2 4 0 0 0 0 0 0 0 - +Do/Th 4 0 0 0 4 0 0 3 3 3 4 2 0 0 0 4 4 0 0 4 0 0 0 0 0 - +Fr/Fr 0 0 2 4 0 4 1 4 3 0 0 4 2 3 4 4 4 1 0 0 0 4 0 0 0 - +Sa/Sa 0 4 0 0 4 4 0 0 4 0 4 3 0 4 4 2 4 2 4 4 0 4 0 4 4 - ``` | Datum / Date | Hinzu / Added | Entfernt / Removed | @@ -158,6 +158,7 @@ Sa/Sa 4 0 4 0 0 4 4 0 0 4 0 4 3 0 4 4 2 4 2 4 4 0 4 0 4 4 | 2026-09-12 | 8815 | 558 | | 2026-09-13 | 18458 | 1438 | | 2026-09-19 | 7250 | 162 | +| 2026-09-20 | 1232 | 29 | ### Abdeckung / Coverage diff --git a/docs/project-statistics/snapshot.json b/docs/project-statistics/snapshot.json index 4e07fd68..edf6aefe 100644 --- a/docs/project-statistics/snapshot.json +++ b/docs/project-statistics/snapshot.json @@ -1 +1 @@ -{"schemaVersion":1,"presetVersion":"0.1.0","methodology":"project-transparency/1","configSha256":"35ecec870453206fe9c0c92c2c0a87c4fb9ef6a6457bad4903d7d5b5f2ee1130","rendererSha256":"a40f565e2f7451d5688103b69e40a7a39e226507cf3ee43144a60af386d65d30","generatedSha256":"66bd8df2606e011eaa30d874ac211197bab64457743be5398cc54f970d1a7040","measurement":{"sourceRevision":"38546cea5dd264b339f17b66005ac93df7446ffd","asOf":"2026-09-19","timeZone":"UTC","windowStart":"2025-09-21","windowWeeks":52,"inputsSha256":"f57d204885bb9c7bb2e1175a68bd33500585a181177b436e9d0c57f1fb1b08c2","totalTextFiles":3483,"totalTextLines":750256,"activeDays":97,"categories":{"Production":{"files":854,"lines":140068},"Tests":{"files":428,"lines":54626},"Documentation":{"files":1600,"lines":384380},"Scripts":{"files":133,"lines":28151},"Configuration":{"files":139,"lines":43165},"DataMedia":{"files":1,"lines":1},"Other":{"files":328,"lines":99865}},"daily":{"2026-02-08":{"added":139076,"removed":29},"2026-03-01":{"added":4960,"removed":39},"2026-03-06":{"added":36824,"removed":149},"2026-03-08":{"added":1616,"removed":1},"2026-03-16":{"added":1345,"removed":356},"2026-03-17":{"added":1150,"removed":86},"2026-03-18":{"added":46,"removed":32},"2026-03-20":{"added":6565,"removed":709},"2026-03-21":{"added":63301,"removed":1705},"2026-03-22":{"added":316337,"removed":4336},"2026-03-23":{"added":8488,"removed":1785},"2026-03-24":{"added":471,"removed":61},"2026-03-25":{"added":1670,"removed":210},"2026-03-27":{"added":31948,"removed":1963},"2026-03-28":{"added":6037,"removed":223},"2026-03-29":{"added":15537,"removed":2397},"2026-03-30":{"added":17062,"removed":4797},"2026-03-31":{"added":277,"removed":0},"2026-04-02":{"added":1902,"removed":2},"2026-04-11":{"added":0,"removed":1651},"2026-04-12":{"added":189,"removed":6},"2026-04-13":{"added":19,"removed":17},"2026-04-17":{"added":84,"removed":14},"2026-04-20":{"added":7912,"removed":309},"2026-04-22":{"added":455,"removed":433},"2026-04-24":{"added":2868,"removed":125},"2026-04-30":{"added":2708,"removed":259},"2026-05-02":{"added":31880,"removed":2176},"2026-05-04":{"added":5558,"removed":2070},"2026-05-05":{"added":648,"removed":22},"2026-05-06":{"added":2374,"removed":184},"2026-05-08":{"added":4835,"removed":204408},"2026-05-09":{"added":2274,"removed":65},"2026-05-10":{"added":3276,"removed":303},"2026-05-12":{"added":2467,"removed":436},"2026-05-13":{"added":1,"removed":1},"2026-05-15":{"added":3,"removed":0},"2026-05-21":{"added":420,"removed":1},"2026-05-22":{"added":2578,"removed":1193},"2026-05-26":{"added":459,"removed":77},"2026-05-28":{"added":952,"removed":10},"2026-05-29":{"added":573,"removed":61},"2026-05-30":{"added":2845,"removed":270},"2026-05-31":{"added":1377,"removed":56},"2026-06-01":{"added":2,"removed":0},"2026-06-02":{"added":96,"removed":47},"2026-06-03":{"added":85,"removed":21},"2026-06-04":{"added":1097,"removed":159},"2026-06-11":{"added":1473,"removed":2171},"2026-06-13":{"added":3620,"removed":308},"2026-06-14":{"added":2109,"removed":130},"2026-06-15":{"added":51,"removed":53},"2026-06-17":{"added":27068,"removed":4},"2026-06-18":{"added":102,"removed":100},"2026-06-19":{"added":1895,"removed":90},"2026-06-20":{"added":420,"removed":5},"2026-06-26":{"added":105,"removed":11},"2026-06-29":{"added":534,"removed":253},"2026-06-30":{"added":79,"removed":88},"2026-07-01":{"added":108,"removed":5},"2026-07-03":{"added":431,"removed":26},"2026-07-04":{"added":1950,"removed":1448},"2026-07-06":{"added":48,"removed":0},"2026-07-10":{"added":6283,"removed":2420},"2026-07-11":{"added":11966,"removed":3631},"2026-07-12":{"added":34047,"removed":945},"2026-07-13":{"added":15096,"removed":498},"2026-07-14":{"added":5748,"removed":204},"2026-07-15":{"added":3837,"removed":427},"2026-07-16":{"added":34780,"removed":851},"2026-07-17":{"added":20769,"removed":1499},"2026-07-18":{"added":45,"removed":45},"2026-07-19":{"added":16222,"removed":3963},"2026-07-21":{"added":5843,"removed":267},"2026-07-22":{"added":93,"removed":5},"2026-07-23":{"added":2140,"removed":186},"2026-07-24":{"added":2606,"removed":297},"2026-07-25":{"added":2627,"removed":212},"2026-07-26":{"added":8851,"removed":1251},"2026-07-28":{"added":3054,"removed":488},"2026-07-29":{"added":10199,"removed":188},"2026-07-31":{"added":11,"removed":5},"2026-08-01":{"added":133,"removed":25},"2026-08-02":{"added":36,"removed":4},"2026-08-08":{"added":4363,"removed":241},"2026-08-09":{"added":11987,"removed":812},"2026-08-13":{"added":4008,"removed":35},"2026-08-15":{"added":1318,"removed":767},"2026-08-17":{"added":76,"removed":23},"2026-08-28":{"added":8319,"removed":317},"2026-08-29":{"added":4628,"removed":290},"2026-08-30":{"added":84289,"removed":465},"2026-09-07":{"added":8253,"removed":44},"2026-09-08":{"added":4141,"removed":2516},"2026-09-12":{"added":8815,"removed":558},"2026-09-13":{"added":18458,"removed":1438},"2026-09-19":{"added":7250,"removed":162}},"omitted":{"binary":68,"symlinks":0,"submodules":0,"excluded":3}}} +{"schemaVersion":1,"presetVersion":"0.1.0","methodology":"project-transparency/1","configSha256":"35ecec870453206fe9c0c92c2c0a87c4fb9ef6a6457bad4903d7d5b5f2ee1130","rendererSha256":"a40f565e2f7451d5688103b69e40a7a39e226507cf3ee43144a60af386d65d30","generatedSha256":"618a8adc1238aff4b05447836abd8d588c5d5c28d3202e3d7a13683b7c19b58e","measurement":{"sourceRevision":"bb9c03d597678e0eb189d9c7f9d14410169da05e","asOf":"2026-09-20","timeZone":"UTC","windowStart":"2025-09-28","windowWeeks":52,"inputsSha256":"3bcde84ff6febab491a3d206c23c8468de8a16afd5685bc3a2c38359667d7471","totalTextFiles":3487,"totalTextLines":751459,"activeDays":98,"categories":{"Production":{"files":854,"lines":140068},"Tests":{"files":429,"lines":55069},"Documentation":{"files":1600,"lines":384417},"Scripts":{"files":136,"lines":28874},"Configuration":{"files":139,"lines":43165},"DataMedia":{"files":1,"lines":1},"Other":{"files":328,"lines":99865}},"daily":{"2026-02-08":{"added":139076,"removed":29},"2026-03-01":{"added":4960,"removed":39},"2026-03-06":{"added":36824,"removed":149},"2026-03-08":{"added":1616,"removed":1},"2026-03-16":{"added":1345,"removed":356},"2026-03-17":{"added":1150,"removed":86},"2026-03-18":{"added":46,"removed":32},"2026-03-20":{"added":6565,"removed":709},"2026-03-21":{"added":63301,"removed":1705},"2026-03-22":{"added":316337,"removed":4336},"2026-03-23":{"added":8488,"removed":1785},"2026-03-24":{"added":471,"removed":61},"2026-03-25":{"added":1670,"removed":210},"2026-03-27":{"added":31948,"removed":1963},"2026-03-28":{"added":6037,"removed":223},"2026-03-29":{"added":15537,"removed":2397},"2026-03-30":{"added":17062,"removed":4797},"2026-03-31":{"added":277,"removed":0},"2026-04-02":{"added":1902,"removed":2},"2026-04-11":{"added":0,"removed":1651},"2026-04-12":{"added":189,"removed":6},"2026-04-13":{"added":19,"removed":17},"2026-04-17":{"added":84,"removed":14},"2026-04-20":{"added":7912,"removed":309},"2026-04-22":{"added":455,"removed":433},"2026-04-24":{"added":2868,"removed":125},"2026-04-30":{"added":2708,"removed":259},"2026-05-02":{"added":31880,"removed":2176},"2026-05-04":{"added":5558,"removed":2070},"2026-05-05":{"added":648,"removed":22},"2026-05-06":{"added":2374,"removed":184},"2026-05-08":{"added":4835,"removed":204408},"2026-05-09":{"added":2274,"removed":65},"2026-05-10":{"added":3276,"removed":303},"2026-05-12":{"added":2467,"removed":436},"2026-05-13":{"added":1,"removed":1},"2026-05-15":{"added":3,"removed":0},"2026-05-21":{"added":420,"removed":1},"2026-05-22":{"added":2578,"removed":1193},"2026-05-26":{"added":459,"removed":77},"2026-05-28":{"added":952,"removed":10},"2026-05-29":{"added":573,"removed":61},"2026-05-30":{"added":2845,"removed":270},"2026-05-31":{"added":1377,"removed":56},"2026-06-01":{"added":2,"removed":0},"2026-06-02":{"added":96,"removed":47},"2026-06-03":{"added":85,"removed":21},"2026-06-04":{"added":1097,"removed":159},"2026-06-11":{"added":1473,"removed":2171},"2026-06-13":{"added":3620,"removed":308},"2026-06-14":{"added":2109,"removed":130},"2026-06-15":{"added":51,"removed":53},"2026-06-17":{"added":27068,"removed":4},"2026-06-18":{"added":102,"removed":100},"2026-06-19":{"added":1895,"removed":90},"2026-06-20":{"added":420,"removed":5},"2026-06-26":{"added":105,"removed":11},"2026-06-29":{"added":534,"removed":253},"2026-06-30":{"added":79,"removed":88},"2026-07-01":{"added":108,"removed":5},"2026-07-03":{"added":431,"removed":26},"2026-07-04":{"added":1950,"removed":1448},"2026-07-06":{"added":48,"removed":0},"2026-07-10":{"added":6283,"removed":2420},"2026-07-11":{"added":11966,"removed":3631},"2026-07-12":{"added":34047,"removed":945},"2026-07-13":{"added":15096,"removed":498},"2026-07-14":{"added":5748,"removed":204},"2026-07-15":{"added":3837,"removed":427},"2026-07-16":{"added":34780,"removed":851},"2026-07-17":{"added":20769,"removed":1499},"2026-07-18":{"added":45,"removed":45},"2026-07-19":{"added":16222,"removed":3963},"2026-07-21":{"added":5843,"removed":267},"2026-07-22":{"added":93,"removed":5},"2026-07-23":{"added":2140,"removed":186},"2026-07-24":{"added":2606,"removed":297},"2026-07-25":{"added":2627,"removed":212},"2026-07-26":{"added":8851,"removed":1251},"2026-07-28":{"added":3054,"removed":488},"2026-07-29":{"added":10199,"removed":188},"2026-07-31":{"added":11,"removed":5},"2026-08-01":{"added":133,"removed":25},"2026-08-02":{"added":36,"removed":4},"2026-08-08":{"added":4363,"removed":241},"2026-08-09":{"added":11987,"removed":812},"2026-08-13":{"added":4008,"removed":35},"2026-08-15":{"added":1318,"removed":767},"2026-08-17":{"added":76,"removed":23},"2026-08-28":{"added":8319,"removed":317},"2026-08-29":{"added":4628,"removed":290},"2026-08-30":{"added":84289,"removed":465},"2026-09-07":{"added":8253,"removed":44},"2026-09-08":{"added":4141,"removed":2516},"2026-09-12":{"added":8815,"removed":558},"2026-09-13":{"added":18458,"removed":1438},"2026-09-19":{"added":7250,"removed":162},"2026-09-20":{"added":1232,"removed":29}},"omitted":{"binary":68,"symlinks":0,"submodules":0,"excluded":3}}} From 7f9f40cf76becb3a2078c9df66ee707e43e0ed32 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Sun, 20 Sep 2026 11:24:32 +0200 Subject: [PATCH 3/6] docs: refresh maintenance delivery statistics --- docs/project-statistics.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/project-statistics.md b/docs/project-statistics.md index 6f218eca..5e465452 100644 --- a/docs/project-statistics.md +++ b/docs/project-statistics.md @@ -1153,27 +1153,27 @@ Profil 2 verwendet Git-getrackte Textdateien und sichtbare Git-Aktivitaet. Die W | Kennzahl / Metric | Wert / Value | |---|---:| -| Textbasis / Text base | 750256 lines | -| Textdateien / Text files | 3483 | +| Textbasis / Text base | 751459 lines | +| Textdateien / Text files | 3487 | | Beobachtbarer Zeitraum / Observable period | 2025-09-28..2026-09-20 | | Aktivtage / Active days | 99 | -| Relevante Commits / Relevant commits | 618 | -| Zeilen je Aktivtag / Lines per active day | 7578.3 | +| Relevante Commits / Relevant commits | 619 | +| Zeilen je Aktivtag / Lines per active day | 7590.5 | | Peak-Tag im Fenster / Peak day in window | 2026-03-22 / 321183 | | Peak-Woche im Fenster / Peak week in window | 2026-03-22 / 373576 | | Laengste Serie / Longest streak | 17 days | -| Speedup vs. 80 lines/day | 94.7x | -| Speedup vs. 125 lines/day | 60.6x | -| Methodik / Methodology | v2; source `4001798bab7d` | +| Speedup vs. 80 lines/day | 94.9x | +| Speedup vs. 125 lines/day | 60.7x | +| Methodik / Methodology | v2; source `bb9c03d59767` | ### Artefaktmix / Artifact Mix ```text -Produktiv / Production [####................] 18.7% | 140068 -Tests [#...................] 7.3% | 54626 -Dokumentation / Documentation [##########..........] 51.2% | 384380 -Skripte / Scripts [#...................] 3.8% | 28151 -Konfiguration / Configuration [#...................] 5.8% | 43165 +Produktiv / Production [####................] 18.6% | 140068 +Tests [#...................] 7.3% | 55069 +Dokumentation / Documentation [##########..........] 51.2% | 384417 +Skripte / Scripts [#...................] 3.8% | 28874 +Konfiguration / Configuration [#...................] 5.7% | 43165 Daten und Medien / Data and media [#...................] 0.0% | 1 Sonstiger Text / Other text [###.................] 13.3% | 99865 ``` @@ -1376,8 +1376,8 @@ Die festen Slots halten den Phasenvergleich auch bei fehlenden oder spaeter erga ```text Scale: 0..100x -80 lines/day [###################.] 94.7x -125 lines/day [############........] 60.6x +80 lines/day [###################.] 94.9x +125 lines/day [############........] 60.7x ``` Die Faktoren vergleichen sichtbare Lieferdichte mit den dokumentierten manuellen Referenzen. Sie messen keine Arbeitszeit. @@ -1390,7 +1390,7 @@ Die Faktoren vergleichen sichtbare Lieferdichte mit den dokumentierten manuellen Scale: 0..10000 lines/day Experienced manual [#...................] 80 Thorsten solo [#...................] 125 -Visible repository [###############.....] 7578.3 +Visible repository [###############.....] 7590.5 ``` Die gemeinsame Skala vergleicht Referenzen und sichtbare Lieferdichte. Sie schreibt die Git-Aktivitaet keiner Person oder KI pauschal zu. @@ -1416,6 +1416,6 @@ DE: Das Fenster beginnt am 2025-09-28 und endet am 2026-09-20. Es enthaelt 99 ak | 2026-06 | 42176 | | 2026-07 | 205615 | | 2026-08 | 122136 | -| 2026-09 | 51635 | +| 2026-09 | 52896 | From b918a1463b6e6bd97d74e8d76e414603e2a9e1f9 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Sun, 20 Sep 2026 11:38:32 +0200 Subject: [PATCH 4/6] chore: synchronize verified maintenance package --- scripts/tests/test_maintenance_execution_context.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/tests/test_maintenance_execution_context.py b/scripts/tests/test_maintenance_execution_context.py index 3092ff56..95f0bbb5 100644 --- a/scripts/tests/test_maintenance_execution_context.py +++ b/scripts/tests/test_maintenance_execution_context.py @@ -12,6 +12,11 @@ from unittest.mock import patch MODULE = Path(__file__).resolve().parents[1] / "lib/maintenance_execution_context.py" +# Consumer packages deliberately omit the Level-0 orchestration manifest and +# installers. Keep their unit contracts runnable through ordinary discovery; +# the complete integration suite remains mandatory in the canonical source. +CANONICAL_SOURCE = (MODULE.parents[2] / "scripts/config/agentic-toolchain-maintenance-files.json").is_file() +central_only = unittest.skipUnless(CANONICAL_SOURCE, "Requires canonical Level-0 source; covered by central/image integration tests") spec = importlib.util.spec_from_file_location("maintenance_execution_context", MODULE) context = importlib.util.module_from_spec(spec) spec.loader.exec_module(context) @@ -112,6 +117,7 @@ def test_binding_rejects_missing_or_symlinked_package_files(self): with self.assertRaises(context.ExecutionContextError): context.source_bindings(root) + @central_only def test_real_package_binds_worker_and_contract(self): bindings = context.source_bindings(MODULE.parents[2]) self.assertIn("scripts/lib/maintenance_container_worker.py", bindings) @@ -193,6 +199,7 @@ def test_worker_refuses_symlink_git_directory(self): run.assert_not_called() @unittest.skipIf(os.name == "nt", "Linux leaf worker; native Windows wrapper is tested separately") + @central_only def test_real_registry_worker_preserves_curated_metadata(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() @@ -210,6 +217,7 @@ def test_real_registry_worker_preserves_curated_metadata(self): self.assertEqual(returned[key], entry["registry"][key]) @unittest.skipIf(os.name == "nt", "Linux leaf worker; native Windows wrapper is tested separately") + @central_only def test_real_propagation_worker_reports_drift_without_writes(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() @@ -223,6 +231,7 @@ def test_real_propagation_worker_reports_drift_without_writes(self): self.assertEqual(result["status"], "Blocked", result) self.assertEqual(before, sorted(p.relative_to(repo).as_posix() for p in repo.rglob("*"))) + @central_only def test_preflight_failure_closes_barrier_before_git(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() @@ -238,6 +247,7 @@ def test_preflight_failure_closes_barrier_before_git(self): self.assertFalse(report["mutationBarrier"]["domainMutationAllowed"]) self.assertEqual(report["findings"][0]["code"], "SandboxPreflightBlocked") + @central_only def test_two_delegated_fast_forwards_follow_all_fetches(self): fixture_spec = importlib.util.spec_from_file_location("fleet_fixtures", Path(__file__).with_name("test_agentic_workspace_maintenance.py")) fixtures = importlib.util.module_from_spec(fixture_spec) @@ -306,6 +316,7 @@ def phase_fixture(self, root, mode="update"): confirm_deep_cleanup=False, output=root / "host-registry.json") return args, evidence + @central_only def test_registry_return_is_translated_and_host_entries_are_preserved(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() @@ -322,6 +333,7 @@ def test_registry_return_is_translated_and_host_entries_are_preserved(self): self.assertEqual(entries[1], {"path": "Other/repo", "level": 2}) self.assertEqual(json.loads(args.report.read_text())["delegatedPhases"][0]["executionContext"], "container") + @central_only def test_lost_container_does_not_modify_registry_or_report(self): with tempfile.TemporaryDirectory() as directory: args, evidence = self.phase_fixture(Path(directory).resolve()) From 142a4c3706e5f57cac2c9fff3720fbf452ea0a56 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Sun, 20 Sep 2026 11:39:02 +0200 Subject: [PATCH 5/6] docs: refresh project transparency snapshot --- docs/project-statistics/report.md | 8 ++++---- docs/project-statistics/snapshot.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/project-statistics/report.md b/docs/project-statistics/report.md index 8f73da64..5dd6441b 100644 --- a/docs/project-statistics/report.md +++ b/docs/project-statistics/report.md @@ -10,21 +10,21 @@ Git-bound inventory and activity; not a measure of quality, learning performance | Kennzahl / Metric | Wert / Value | | --- | --- | | Textdateien / Text files | 3487 | -| Textzeilen / Text lines | 751459 | +| Textzeilen / Text lines | 751471 | | Aktivtage / Active days | 98 | | Stichtag / As of | 2026-09-20 | | Fensterbeginn / Window start | 2025-09-28 | | Zeitzone / Time zone | UTC | Quellrevision / Source revision: -bb9c03d597678e0eb189d9c7f9d14410169da05e +b918a1463b6e6bd97d74e8d76e414603e2a9e1f9 ### Artefakte / Artifacts | Kategorie / Category | Dateien / Files | Zeilen / Lines | | --- | ---: | ---: | | Production | 854 | 140068 | -| Tests | 429 | 55069 | +| Tests | 429 | 55081 | | Documentation | 1600 | 384417 | | Scripts | 136 | 28874 | | Configuration | 139 | 43165 | @@ -158,7 +158,7 @@ Sa/Sa 0 4 0 0 4 4 0 0 4 0 4 3 0 4 4 2 4 2 4 4 0 4 0 4 4 - | 2026-09-12 | 8815 | 558 | | 2026-09-13 | 18458 | 1438 | | 2026-09-19 | 7250 | 162 | -| 2026-09-20 | 1232 | 29 | +| 2026-09-20 | 1244 | 29 | ### Abdeckung / Coverage diff --git a/docs/project-statistics/snapshot.json b/docs/project-statistics/snapshot.json index edf6aefe..e0d9ab44 100644 --- a/docs/project-statistics/snapshot.json +++ b/docs/project-statistics/snapshot.json @@ -1 +1 @@ -{"schemaVersion":1,"presetVersion":"0.1.0","methodology":"project-transparency/1","configSha256":"35ecec870453206fe9c0c92c2c0a87c4fb9ef6a6457bad4903d7d5b5f2ee1130","rendererSha256":"a40f565e2f7451d5688103b69e40a7a39e226507cf3ee43144a60af386d65d30","generatedSha256":"618a8adc1238aff4b05447836abd8d588c5d5c28d3202e3d7a13683b7c19b58e","measurement":{"sourceRevision":"bb9c03d597678e0eb189d9c7f9d14410169da05e","asOf":"2026-09-20","timeZone":"UTC","windowStart":"2025-09-28","windowWeeks":52,"inputsSha256":"3bcde84ff6febab491a3d206c23c8468de8a16afd5685bc3a2c38359667d7471","totalTextFiles":3487,"totalTextLines":751459,"activeDays":98,"categories":{"Production":{"files":854,"lines":140068},"Tests":{"files":429,"lines":55069},"Documentation":{"files":1600,"lines":384417},"Scripts":{"files":136,"lines":28874},"Configuration":{"files":139,"lines":43165},"DataMedia":{"files":1,"lines":1},"Other":{"files":328,"lines":99865}},"daily":{"2026-02-08":{"added":139076,"removed":29},"2026-03-01":{"added":4960,"removed":39},"2026-03-06":{"added":36824,"removed":149},"2026-03-08":{"added":1616,"removed":1},"2026-03-16":{"added":1345,"removed":356},"2026-03-17":{"added":1150,"removed":86},"2026-03-18":{"added":46,"removed":32},"2026-03-20":{"added":6565,"removed":709},"2026-03-21":{"added":63301,"removed":1705},"2026-03-22":{"added":316337,"removed":4336},"2026-03-23":{"added":8488,"removed":1785},"2026-03-24":{"added":471,"removed":61},"2026-03-25":{"added":1670,"removed":210},"2026-03-27":{"added":31948,"removed":1963},"2026-03-28":{"added":6037,"removed":223},"2026-03-29":{"added":15537,"removed":2397},"2026-03-30":{"added":17062,"removed":4797},"2026-03-31":{"added":277,"removed":0},"2026-04-02":{"added":1902,"removed":2},"2026-04-11":{"added":0,"removed":1651},"2026-04-12":{"added":189,"removed":6},"2026-04-13":{"added":19,"removed":17},"2026-04-17":{"added":84,"removed":14},"2026-04-20":{"added":7912,"removed":309},"2026-04-22":{"added":455,"removed":433},"2026-04-24":{"added":2868,"removed":125},"2026-04-30":{"added":2708,"removed":259},"2026-05-02":{"added":31880,"removed":2176},"2026-05-04":{"added":5558,"removed":2070},"2026-05-05":{"added":648,"removed":22},"2026-05-06":{"added":2374,"removed":184},"2026-05-08":{"added":4835,"removed":204408},"2026-05-09":{"added":2274,"removed":65},"2026-05-10":{"added":3276,"removed":303},"2026-05-12":{"added":2467,"removed":436},"2026-05-13":{"added":1,"removed":1},"2026-05-15":{"added":3,"removed":0},"2026-05-21":{"added":420,"removed":1},"2026-05-22":{"added":2578,"removed":1193},"2026-05-26":{"added":459,"removed":77},"2026-05-28":{"added":952,"removed":10},"2026-05-29":{"added":573,"removed":61},"2026-05-30":{"added":2845,"removed":270},"2026-05-31":{"added":1377,"removed":56},"2026-06-01":{"added":2,"removed":0},"2026-06-02":{"added":96,"removed":47},"2026-06-03":{"added":85,"removed":21},"2026-06-04":{"added":1097,"removed":159},"2026-06-11":{"added":1473,"removed":2171},"2026-06-13":{"added":3620,"removed":308},"2026-06-14":{"added":2109,"removed":130},"2026-06-15":{"added":51,"removed":53},"2026-06-17":{"added":27068,"removed":4},"2026-06-18":{"added":102,"removed":100},"2026-06-19":{"added":1895,"removed":90},"2026-06-20":{"added":420,"removed":5},"2026-06-26":{"added":105,"removed":11},"2026-06-29":{"added":534,"removed":253},"2026-06-30":{"added":79,"removed":88},"2026-07-01":{"added":108,"removed":5},"2026-07-03":{"added":431,"removed":26},"2026-07-04":{"added":1950,"removed":1448},"2026-07-06":{"added":48,"removed":0},"2026-07-10":{"added":6283,"removed":2420},"2026-07-11":{"added":11966,"removed":3631},"2026-07-12":{"added":34047,"removed":945},"2026-07-13":{"added":15096,"removed":498},"2026-07-14":{"added":5748,"removed":204},"2026-07-15":{"added":3837,"removed":427},"2026-07-16":{"added":34780,"removed":851},"2026-07-17":{"added":20769,"removed":1499},"2026-07-18":{"added":45,"removed":45},"2026-07-19":{"added":16222,"removed":3963},"2026-07-21":{"added":5843,"removed":267},"2026-07-22":{"added":93,"removed":5},"2026-07-23":{"added":2140,"removed":186},"2026-07-24":{"added":2606,"removed":297},"2026-07-25":{"added":2627,"removed":212},"2026-07-26":{"added":8851,"removed":1251},"2026-07-28":{"added":3054,"removed":488},"2026-07-29":{"added":10199,"removed":188},"2026-07-31":{"added":11,"removed":5},"2026-08-01":{"added":133,"removed":25},"2026-08-02":{"added":36,"removed":4},"2026-08-08":{"added":4363,"removed":241},"2026-08-09":{"added":11987,"removed":812},"2026-08-13":{"added":4008,"removed":35},"2026-08-15":{"added":1318,"removed":767},"2026-08-17":{"added":76,"removed":23},"2026-08-28":{"added":8319,"removed":317},"2026-08-29":{"added":4628,"removed":290},"2026-08-30":{"added":84289,"removed":465},"2026-09-07":{"added":8253,"removed":44},"2026-09-08":{"added":4141,"removed":2516},"2026-09-12":{"added":8815,"removed":558},"2026-09-13":{"added":18458,"removed":1438},"2026-09-19":{"added":7250,"removed":162},"2026-09-20":{"added":1232,"removed":29}},"omitted":{"binary":68,"symlinks":0,"submodules":0,"excluded":3}}} +{"schemaVersion":1,"presetVersion":"0.1.0","methodology":"project-transparency/1","configSha256":"35ecec870453206fe9c0c92c2c0a87c4fb9ef6a6457bad4903d7d5b5f2ee1130","rendererSha256":"a40f565e2f7451d5688103b69e40a7a39e226507cf3ee43144a60af386d65d30","generatedSha256":"8dacf2e0a1f6b75e42ff91f7180419216a9f4a776aa9fd7ef7a046981d62fc75","measurement":{"sourceRevision":"b918a1463b6e6bd97d74e8d76e414603e2a9e1f9","asOf":"2026-09-20","timeZone":"UTC","windowStart":"2025-09-28","windowWeeks":52,"inputsSha256":"894b0a9b19cfad6ab68b3a467b032e6b1a3abf7806ffd9371fd9443cc95fa47d","totalTextFiles":3487,"totalTextLines":751471,"activeDays":98,"categories":{"Production":{"files":854,"lines":140068},"Tests":{"files":429,"lines":55081},"Documentation":{"files":1600,"lines":384417},"Scripts":{"files":136,"lines":28874},"Configuration":{"files":139,"lines":43165},"DataMedia":{"files":1,"lines":1},"Other":{"files":328,"lines":99865}},"daily":{"2026-02-08":{"added":139076,"removed":29},"2026-03-01":{"added":4960,"removed":39},"2026-03-06":{"added":36824,"removed":149},"2026-03-08":{"added":1616,"removed":1},"2026-03-16":{"added":1345,"removed":356},"2026-03-17":{"added":1150,"removed":86},"2026-03-18":{"added":46,"removed":32},"2026-03-20":{"added":6565,"removed":709},"2026-03-21":{"added":63301,"removed":1705},"2026-03-22":{"added":316337,"removed":4336},"2026-03-23":{"added":8488,"removed":1785},"2026-03-24":{"added":471,"removed":61},"2026-03-25":{"added":1670,"removed":210},"2026-03-27":{"added":31948,"removed":1963},"2026-03-28":{"added":6037,"removed":223},"2026-03-29":{"added":15537,"removed":2397},"2026-03-30":{"added":17062,"removed":4797},"2026-03-31":{"added":277,"removed":0},"2026-04-02":{"added":1902,"removed":2},"2026-04-11":{"added":0,"removed":1651},"2026-04-12":{"added":189,"removed":6},"2026-04-13":{"added":19,"removed":17},"2026-04-17":{"added":84,"removed":14},"2026-04-20":{"added":7912,"removed":309},"2026-04-22":{"added":455,"removed":433},"2026-04-24":{"added":2868,"removed":125},"2026-04-30":{"added":2708,"removed":259},"2026-05-02":{"added":31880,"removed":2176},"2026-05-04":{"added":5558,"removed":2070},"2026-05-05":{"added":648,"removed":22},"2026-05-06":{"added":2374,"removed":184},"2026-05-08":{"added":4835,"removed":204408},"2026-05-09":{"added":2274,"removed":65},"2026-05-10":{"added":3276,"removed":303},"2026-05-12":{"added":2467,"removed":436},"2026-05-13":{"added":1,"removed":1},"2026-05-15":{"added":3,"removed":0},"2026-05-21":{"added":420,"removed":1},"2026-05-22":{"added":2578,"removed":1193},"2026-05-26":{"added":459,"removed":77},"2026-05-28":{"added":952,"removed":10},"2026-05-29":{"added":573,"removed":61},"2026-05-30":{"added":2845,"removed":270},"2026-05-31":{"added":1377,"removed":56},"2026-06-01":{"added":2,"removed":0},"2026-06-02":{"added":96,"removed":47},"2026-06-03":{"added":85,"removed":21},"2026-06-04":{"added":1097,"removed":159},"2026-06-11":{"added":1473,"removed":2171},"2026-06-13":{"added":3620,"removed":308},"2026-06-14":{"added":2109,"removed":130},"2026-06-15":{"added":51,"removed":53},"2026-06-17":{"added":27068,"removed":4},"2026-06-18":{"added":102,"removed":100},"2026-06-19":{"added":1895,"removed":90},"2026-06-20":{"added":420,"removed":5},"2026-06-26":{"added":105,"removed":11},"2026-06-29":{"added":534,"removed":253},"2026-06-30":{"added":79,"removed":88},"2026-07-01":{"added":108,"removed":5},"2026-07-03":{"added":431,"removed":26},"2026-07-04":{"added":1950,"removed":1448},"2026-07-06":{"added":48,"removed":0},"2026-07-10":{"added":6283,"removed":2420},"2026-07-11":{"added":11966,"removed":3631},"2026-07-12":{"added":34047,"removed":945},"2026-07-13":{"added":15096,"removed":498},"2026-07-14":{"added":5748,"removed":204},"2026-07-15":{"added":3837,"removed":427},"2026-07-16":{"added":34780,"removed":851},"2026-07-17":{"added":20769,"removed":1499},"2026-07-18":{"added":45,"removed":45},"2026-07-19":{"added":16222,"removed":3963},"2026-07-21":{"added":5843,"removed":267},"2026-07-22":{"added":93,"removed":5},"2026-07-23":{"added":2140,"removed":186},"2026-07-24":{"added":2606,"removed":297},"2026-07-25":{"added":2627,"removed":212},"2026-07-26":{"added":8851,"removed":1251},"2026-07-28":{"added":3054,"removed":488},"2026-07-29":{"added":10199,"removed":188},"2026-07-31":{"added":11,"removed":5},"2026-08-01":{"added":133,"removed":25},"2026-08-02":{"added":36,"removed":4},"2026-08-08":{"added":4363,"removed":241},"2026-08-09":{"added":11987,"removed":812},"2026-08-13":{"added":4008,"removed":35},"2026-08-15":{"added":1318,"removed":767},"2026-08-17":{"added":76,"removed":23},"2026-08-28":{"added":8319,"removed":317},"2026-08-29":{"added":4628,"removed":290},"2026-08-30":{"added":84289,"removed":465},"2026-09-07":{"added":8253,"removed":44},"2026-09-08":{"added":4141,"removed":2516},"2026-09-12":{"added":8815,"removed":558},"2026-09-13":{"added":18458,"removed":1438},"2026-09-19":{"added":7250,"removed":162},"2026-09-20":{"added":1244,"removed":29}},"omitted":{"binary":68,"symlinks":0,"submodules":0,"excluded":3}}} From 222b5f774ed6dfd7e1da2d96a7929c844d989bda Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Sun, 20 Sep 2026 11:39:19 +0200 Subject: [PATCH 6/6] docs: refresh maintenance delivery statistics --- docs/project-statistics.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/project-statistics.md b/docs/project-statistics.md index 5e465452..69b038ac 100644 --- a/docs/project-statistics.md +++ b/docs/project-statistics.md @@ -1153,24 +1153,24 @@ Profil 2 verwendet Git-getrackte Textdateien und sichtbare Git-Aktivitaet. Die W | Kennzahl / Metric | Wert / Value | |---|---:| -| Textbasis / Text base | 751459 lines | +| Textbasis / Text base | 751471 lines | | Textdateien / Text files | 3487 | | Beobachtbarer Zeitraum / Observable period | 2025-09-28..2026-09-20 | | Aktivtage / Active days | 99 | -| Relevante Commits / Relevant commits | 619 | -| Zeilen je Aktivtag / Lines per active day | 7590.5 | +| Relevante Commits / Relevant commits | 620 | +| Zeilen je Aktivtag / Lines per active day | 7590.6 | | Peak-Tag im Fenster / Peak day in window | 2026-03-22 / 321183 | | Peak-Woche im Fenster / Peak week in window | 2026-03-22 / 373576 | | Laengste Serie / Longest streak | 17 days | | Speedup vs. 80 lines/day | 94.9x | | Speedup vs. 125 lines/day | 60.7x | -| Methodik / Methodology | v2; source `bb9c03d59767` | +| Methodik / Methodology | v2; source `b918a1463b6e` | ### Artefaktmix / Artifact Mix ```text Produktiv / Production [####................] 18.6% | 140068 -Tests [#...................] 7.3% | 55069 +Tests [#...................] 7.3% | 55081 Dokumentation / Documentation [##########..........] 51.2% | 384417 Skripte / Scripts [#...................] 3.8% | 28874 Konfiguration / Configuration [#...................] 5.7% | 43165 @@ -1390,7 +1390,7 @@ Die Faktoren vergleichen sichtbare Lieferdichte mit den dokumentierten manuellen Scale: 0..10000 lines/day Experienced manual [#...................] 80 Thorsten solo [#...................] 125 -Visible repository [###############.....] 7590.5 +Visible repository [###############.....] 7590.6 ``` Die gemeinsame Skala vergleicht Referenzen und sichtbare Lieferdichte. Sie schreibt die Git-Aktivitaet keiner Person oder KI pauschal zu. @@ -1416,6 +1416,6 @@ DE: Das Fenster beginnt am 2025-09-28 und endet am 2026-09-20. Es enthaelt 99 ak | 2026-06 | 42176 | | 2026-07 | 205615 | | 2026-08 | 122136 | -| 2026-09 | 52896 | +| 2026-09 | 52908 |