feat(devkit): add resumable workspace automation and UI review tooling
Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
This commit is contained in:
Executable
+466
@@ -0,0 +1,466 @@
|
||||
"""Real fixture processes and Git worktrees; never invoke product/remote mutations."""
|
||||
|
||||
from argparse import Namespace
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import runner
|
||||
from govoplan_devkit.checkpoints import Checkpoints
|
||||
from govoplan_devkit.common import (
|
||||
atomic_json,
|
||||
digest,
|
||||
read_json,
|
||||
resource_lock,
|
||||
state_root,
|
||||
)
|
||||
from govoplan_devkit.workspace import load_project, source_fingerprint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def example(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg-state"))
|
||||
workspace = tmp_path / "workspace"
|
||||
repo = workspace / "example"
|
||||
repo.mkdir(parents=True)
|
||||
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
||||
(repo / "source.txt").write_text("first\n")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "source.txt"], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"-qm",
|
||||
"fixture",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
project = tmp_path / "project.json"
|
||||
project.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Example",
|
||||
"repositories": [{"name": "example", "path": "example"}],
|
||||
"checks": [],
|
||||
"profiles": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
args = Namespace(
|
||||
workspace_root=workspace,
|
||||
project=project,
|
||||
state_dir=tmp_path / "state",
|
||||
dry_run=False,
|
||||
jobs=2,
|
||||
profile="quick",
|
||||
resume=None,
|
||||
)
|
||||
return args, repo
|
||||
|
||||
|
||||
def stage(repo, name="one", code="print('ok')", **extra):
|
||||
return {
|
||||
"id": name,
|
||||
"title": name,
|
||||
"argv": [sys.executable, "-c", code],
|
||||
"cwd": str(repo),
|
||||
"timeout_seconds": 10,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def run(args, stages):
|
||||
# Environment inspection is tested separately; keep fixture executions cheap/deterministic.
|
||||
with patch.object(runner, "environment_fingerprint", return_value="fixture-env"):
|
||||
return runner.run_checks(args, stages)
|
||||
|
||||
|
||||
def test_success_receipt_and_compact_summary(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
assert result["status"] == "passed"
|
||||
receipt = runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])
|
||||
assert receipt["stages"][0]["exit_code"] == 0
|
||||
assert Path(receipt["stages"][0]["log_path"]).read_text() == "ok\n"
|
||||
assert runner.summarize(receipt)["counts"] == {"passed": 1}
|
||||
assert Path(result["receipt_path"]).stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_native_environment_binds_selected_checkout(tmp_path, monkeypatch):
|
||||
from govoplan_devkit.environment import execution_environment
|
||||
|
||||
monkeypatch.setenv("GOVOPLAN_WORKSPACE_ROOT", "/another/workspace")
|
||||
monkeypatch.setenv("GOVOPLAN_CORE_ROOT", "/another/core")
|
||||
monkeypatch.setenv("GOVOPLAN_CORE_SOURCE_ROOT", "/another/source")
|
||||
project = load_project(tmp_path)
|
||||
env = execution_environment(
|
||||
tmp_path, project, {"python": sys.executable, "node": "node", "npm": "npm"}
|
||||
)
|
||||
assert env["GOVOPLAN_WORKSPACE_ROOT"] == str(tmp_path)
|
||||
assert env["GOVOPLAN_CORE_ROOT"] == str(tmp_path / "govoplan-core")
|
||||
assert env["GOVOPLAN_CORE_SOURCE_ROOT"] == str(tmp_path / "govoplan-core")
|
||||
|
||||
|
||||
def test_portable_environment_does_not_inject_govoplan_scope(example, monkeypatch):
|
||||
from govoplan_devkit.environment import execution_environment
|
||||
|
||||
args, _ = example
|
||||
for key in (
|
||||
"GOVOPLAN_WORKSPACE_ROOT",
|
||||
"GOVOPLAN_CORE_ROOT",
|
||||
"GOVOPLAN_CORE_SOURCE_ROOT",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
env = execution_environment(
|
||||
args.workspace_root,
|
||||
load_project(args.workspace_root, args.project),
|
||||
{"python": sys.executable, "node": "node", "npm": "npm"},
|
||||
)
|
||||
assert "GOVOPLAN_WORKSPACE_ROOT" not in env
|
||||
|
||||
|
||||
def test_failure_skips_dependents_but_runs_independent_check(example):
|
||||
args, repo = example
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(repo, "bad", "raise SystemExit(4)"),
|
||||
stage(repo, "dependent", deps=["bad"]),
|
||||
stage(repo, "independent"),
|
||||
],
|
||||
)
|
||||
states = {item["id"]: item["status"] for item in result["stages"]}
|
||||
assert states == {"bad": "failed", "dependent": "skipped", "independent": "passed"}
|
||||
assert result["_exit_code"] == 1
|
||||
|
||||
|
||||
def test_timeout_is_not_a_pass(example):
|
||||
args, repo = example
|
||||
result = run(
|
||||
args, [stage(repo, code="import time; time.sleep(10)", timeout_seconds=0.1)]
|
||||
)
|
||||
assert result["stages"][0]["status"] == "timed_out"
|
||||
|
||||
|
||||
def test_dry_run_does_not_create_state(example):
|
||||
args, repo = example
|
||||
args.dry_run = True
|
||||
result = run(args, [stage(repo, code="raise SystemExit(9)")])
|
||||
assert result["status"] == "planned"
|
||||
assert not args.state_dir.exists()
|
||||
|
||||
|
||||
def test_empty_plan_is_not_claimed_as_verified(example):
|
||||
args, _ = example
|
||||
assert run(args, [])["status"] == "not_run"
|
||||
|
||||
|
||||
def test_plan_validation_rejects_cycles_unknowns_duplicate_ids_escape(example):
|
||||
args, repo = example
|
||||
invalid = [
|
||||
[stage(repo, deps=["unknown"])],
|
||||
[stage(repo, "a", deps=["b"]), stage(repo, "b", deps=["a"])],
|
||||
[stage(repo), stage(repo)],
|
||||
[stage(repo, "../escape")],
|
||||
[stage(repo.parent.parent)],
|
||||
]
|
||||
for plan in invalid:
|
||||
with pytest.raises(ValueError):
|
||||
runner.validate_stages(plan, args.workspace_root, {})
|
||||
|
||||
|
||||
def test_source_identity_includes_staged_unstaged_and_untracked_bytes(example):
|
||||
args, repo = example
|
||||
project = load_project(args.workspace_root, args.project)
|
||||
first = source_fingerprint(project)
|
||||
(repo / "source.txt").write_text("second\n")
|
||||
second = source_fingerprint(project)
|
||||
assert second != first
|
||||
subprocess.run(["git", "-C", str(repo), "add", "source.txt"], check=True)
|
||||
third = source_fingerprint(project)
|
||||
assert third != second
|
||||
(repo / "extra.txt").write_text("extra")
|
||||
assert source_fingerprint(project) != third
|
||||
|
||||
|
||||
def test_source_mutation_during_run_invalidates_receipt(example):
|
||||
args, repo = example
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(
|
||||
repo,
|
||||
code="from pathlib import Path; Path('source.txt').write_text('changed')",
|
||||
)
|
||||
],
|
||||
)
|
||||
assert result["stages"][0]["status"] == "stale"
|
||||
assert result["stages"][0]["checkpoint_verified"] is False
|
||||
assert result["status"] == "stale"
|
||||
|
||||
|
||||
def test_resume_reuses_only_identical_plan_and_inputs(example):
|
||||
args, repo = example
|
||||
plan = [stage(repo)]
|
||||
first = run(args, plan)
|
||||
args.resume = first["run_id"]
|
||||
second = run(args, plan)
|
||||
assert second["stages"][0]["reused_from"] == first["run_id"]
|
||||
changed_plan = run(args, [stage(repo, code="print('different')")])
|
||||
assert changed_plan["status"] == "passed"
|
||||
assert "reused_from" not in changed_plan["stages"][0]
|
||||
assert Path(changed_plan["stages"][0]["log_path"]).read_text() == "different\n"
|
||||
(repo / "source.txt").write_text("changed")
|
||||
changed_source = run(args, plan)
|
||||
assert changed_source["status"] == "passed"
|
||||
assert "reused_from" not in changed_source["stages"][0]
|
||||
assert changed_source["stages"][0]["cache_key"] != first["stages"][0]["cache_key"]
|
||||
|
||||
|
||||
def test_receipt_integrity_and_foreign_id_are_rejected(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
path = Path(result["receipt_path"])
|
||||
record = read_json(path)
|
||||
record["status"] = "fabricated"
|
||||
atomic_json(path, record)
|
||||
with pytest.raises(ValueError, match="integrity"):
|
||||
runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])
|
||||
with pytest.raises(ValueError):
|
||||
runner.read_receipt(args.workspace_root, args.state_dir, "../other")
|
||||
|
||||
|
||||
def test_shared_resources_serialize_stages(example):
|
||||
args, repo = example
|
||||
active = 0
|
||||
maximum = 0
|
||||
lock = threading.Lock()
|
||||
original = runner._execute_stage_command
|
||||
|
||||
def execute(*args, **kwargs):
|
||||
nonlocal active, maximum
|
||||
with lock:
|
||||
active += 1
|
||||
maximum = max(maximum, active)
|
||||
try:
|
||||
time.sleep(0.04)
|
||||
return original(*args, **kwargs)
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
|
||||
with patch.object(runner, "_execute_stage_command", side_effect=execute):
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(repo, "a", resources=["shared"]),
|
||||
stage(repo, "b", resources=["shared"]),
|
||||
],
|
||||
)
|
||||
assert result["status"] == "passed"
|
||||
assert maximum == 1
|
||||
|
||||
|
||||
def test_cross_process_resource_conflict_is_explicit(example):
|
||||
args, repo = example
|
||||
locks = state_root(args.workspace_root) / "resource-locks"
|
||||
with resource_lock(locks, "shared"):
|
||||
result = run(args, [stage(repo, resources=["shared"])])
|
||||
assert result["stages"][0]["status"] == "blocked"
|
||||
|
||||
|
||||
def test_output_is_bounded_and_known_secrets_redacted(example, monkeypatch):
|
||||
args, repo = example
|
||||
monkeypatch.setenv("FIXTURE_SECRET", "private-fixture-credential")
|
||||
with patch.object(runner, "MAX_LOG_BYTES", 1024):
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(
|
||||
repo,
|
||||
code="import os; print(os.environ['FIXTURE_SECRET']); print('x'*10000)",
|
||||
)
|
||||
],
|
||||
)
|
||||
record = result["stages"][0]
|
||||
output = Path(record["log_path"]).read_text()
|
||||
assert record["output_truncated"] is True
|
||||
assert "private-fixture-credential" not in output
|
||||
assert "[redacted]" in output
|
||||
assert len(output) < 2000
|
||||
|
||||
|
||||
def test_symlinked_state_is_rejected(example, tmp_path):
|
||||
args, repo = example
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
args.state_dir.symlink_to(target, target_is_directory=True)
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
run(args, [stage(repo)])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--assume-unchanged", "--skip-worktree"])
|
||||
def test_hidden_tracked_edits_invalidate_source_identity(example, flag):
|
||||
args, repo = example
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "update-index", flag, "source.txt"], check=True
|
||||
)
|
||||
project = load_project(args.workspace_root, args.project)
|
||||
initial = source_fingerprint(project)
|
||||
(repo / "source.txt").write_text("hidden edit\n")
|
||||
assert source_fingerprint(project) != initial
|
||||
|
||||
|
||||
def test_failed_final_snapshot_never_persists_a_passing_receipt(example):
|
||||
args, repo = example
|
||||
events = []
|
||||
args.on_progress = events.append
|
||||
original = Checkpoints.source
|
||||
|
||||
def fingerprint(self, *values, **kwargs):
|
||||
if events and events[-1]["phase"] == "finalizing":
|
||||
raise ValueError("unreadable source")
|
||||
return original(self, *values, **kwargs)
|
||||
|
||||
with patch.object(Checkpoints, "source", fingerprint):
|
||||
result = run(args, [stage(repo)])
|
||||
assert result["status"] == "stale"
|
||||
assert result["snapshot_verified"] is False
|
||||
assert result["_exit_code"] == 1
|
||||
assert "unreadable source" in result["invalidated_reason"]
|
||||
assert (
|
||||
runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])[
|
||||
"status"
|
||||
]
|
||||
== "stale"
|
||||
)
|
||||
|
||||
|
||||
def test_resume_rejects_a_modified_cached_log(example):
|
||||
args, repo = example
|
||||
plan = [stage(repo)]
|
||||
first = run(args, plan)
|
||||
Path(first["stages"][0]["log_path"]).write_text("altered")
|
||||
args.resume = first["run_id"]
|
||||
result = run(args, plan)
|
||||
assert result["status"] == "failed"
|
||||
assert result["stages"][0]["status"] == "failed"
|
||||
assert "log integrity" in result["stages"][0]["error"]
|
||||
assert "reused_from" not in result["stages"][0]
|
||||
|
||||
|
||||
def test_receipt_argument_redaction_applies_to_unknown_separate_values(example):
|
||||
args, repo = example
|
||||
plan = stage(repo)
|
||||
plan["argv"].extend(["--password", "fixture-not-from-environment"])
|
||||
result = run(args, [plan])
|
||||
assert (
|
||||
"fixture-not-from-environment" not in Path(result["receipt_path"]).read_text()
|
||||
)
|
||||
|
||||
|
||||
def recovery_parser():
|
||||
parser = argparse.ArgumentParser()
|
||||
runner.register(parser.add_subparsers())
|
||||
return parser
|
||||
|
||||
|
||||
def test_recovery_requires_manual_survivor_confirmation(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
record = read_json(Path(result["receipt_path"]))
|
||||
record["status"] = "running"
|
||||
record["snapshot_verified"] = False
|
||||
atomic_json(Path(result["receipt_path"]), runner._seal(record))
|
||||
parsed = recovery_parser().parse_args(
|
||||
["recover", result["run_id"], "--apply"], namespace=args
|
||||
)
|
||||
with pytest.raises(ValueError, match="manual verification"):
|
||||
parsed.handler(parsed)
|
||||
|
||||
|
||||
def test_recovery_does_not_overwrite_completion_between_reads(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
completed = runner.read_receipt(
|
||||
args.workspace_root, args.state_dir, result["run_id"]
|
||||
)
|
||||
initial = {**completed, "status": "running"}
|
||||
parsed = recovery_parser().parse_args(
|
||||
["recover", result["run_id"], "--apply", "--confirm-processes-stopped"],
|
||||
namespace=args,
|
||||
)
|
||||
with (
|
||||
patch.object(runner, "read_receipt", side_effect=[initial, completed]),
|
||||
patch.object(runner, "atomic_json") as write,
|
||||
):
|
||||
assert parsed.handler(parsed)["status"] == "unchanged"
|
||||
write.assert_not_called()
|
||||
|
||||
|
||||
def test_active_owner_lock_blocks_recovery(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
record = read_json(Path(result["receipt_path"]))
|
||||
record.update(status="running", snapshot_verified=False)
|
||||
atomic_json(Path(result["receipt_path"]), runner._seal(record))
|
||||
parsed = recovery_parser().parse_args(
|
||||
["recover", result["run_id"], "--apply", "--confirm-processes-stopped"],
|
||||
namespace=args,
|
||||
)
|
||||
with resource_lock(
|
||||
state_root(args.workspace_root, args.state_dir) / "locks",
|
||||
"run:" + result["run_id"],
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="busy"):
|
||||
parsed.handler(parsed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
"unknown_status",
|
||||
"unverified_pass",
|
||||
"duplicate_stage",
|
||||
"nonboolean_verification",
|
||||
"nonpassing_stage",
|
||||
],
|
||||
)
|
||||
def test_even_correctly_hashed_receipts_must_have_consistent_states(example, mutation):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
path = Path(result["receipt_path"])
|
||||
record = read_json(path)
|
||||
if mutation == "unknown_status":
|
||||
record["status"] = "wonderful"
|
||||
elif mutation == "unverified_pass":
|
||||
record["snapshot_verified"] = False
|
||||
elif mutation == "duplicate_stage":
|
||||
record["stages"].append(record["stages"][0])
|
||||
elif mutation == "nonboolean_verification":
|
||||
record["snapshot_verified"] = "true"
|
||||
else:
|
||||
record["stages"][0]["status"] = "failed"
|
||||
# Deliberately construct an externally corrupted but correctly hashed record;
|
||||
# the runner's writer now rejects inconsistent checkpoint state before sealing.
|
||||
record["integrity_sha256"] = digest(
|
||||
{key: value for key, value in record.items() if key != "integrity_sha256"}
|
||||
)
|
||||
atomic_json(path, record)
|
||||
with pytest.raises(ValueError):
|
||||
runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])
|
||||
Reference in New Issue
Block a user