import hashlib import json import subprocess import tempfile import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch from wbv2_local_preparation import prepare_local_contracts from wbv2_task_dispatch import input_signature class LocalPreparationTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) (self.root / "my-tools").mkdir() (self.root / "parent.json").write_text("{}") self.context = {"task": "TASK", "parentSha256": "a" * 64, "parentManifest": "parent.json"} self.jobs = [{"id": "MESH-EXTENDED-AUTHORING", "ownedPaths": []}] self.script = self.root / "my-tools/prepare_wbv2_mesh_extended_workflow.py" self.script.write_text('JOB = "MESH-EXTENDED-AUTHORING"\n') self.runner = SimpleNamespace(root=self.root, directory=self.root / "control", stop_requested=False, policy={"checkTimeoutSeconds": 120}, state={"jobs": {}}, local_contract_path=Mock(return_value=None), event=Mock(), run_process=Mock(return_value={"exitCode": 0, "interrupted": False, "timedOut": False, "logLimit": False})) authority = patch("wbv2_autopilot.queue_context", return_value=self.context) self.authority_patch = authority self.authority = authority.start() self.addCleanup(authority.stop) admission = patch("wbv2_task_dispatch.prepared_contract", return_value=({"result": {}}, [])) self.admission_patch = admission self.admission = admission.start() self.addCleanup(admission.stop) def prepare(self): return prepare_local_contracts(self.runner, self.context, self.jobs) def test_literal_job_matches_existing_nonstandard_filename(self): self.assertEqual([self.jobs[0]["id"]], self.prepare()) self.assertEqual(["python3", str(self.script.relative_to(self.root))], self.runner.run_process.call_args.args[0]) def test_failure_is_not_retried_until_input_changes(self): self.runner.run_process.return_value["exitCode"] = 1 self.assertEqual([], self.prepare()) self.assertEqual([], self.prepare()) self.assertEqual(1, self.runner.run_process.call_count) self.script.write_text(self.script.read_text() + "# repair\n") self.prepare() self.assertEqual(2, self.runner.run_process.call_count) def test_dependency_change_wakes_and_retries_failed_preparation(self): dependency = self.root / "input.json" dependency.write_text("1") self.jobs[0]["ownedPaths"] = ["input.json"] self.runner.run_process.return_value["exitCode"] = 1 self.prepare() before = input_signature(self.runner, self.context, self.jobs) dependency.write_text("2") self.assertNotEqual(before, input_signature(self.runner, self.context, self.jobs)) self.prepare() self.assertEqual(2, self.runner.run_process.call_count) def test_new_recipe_and_edit_wake_waiter(self): self.script.unlink() before = input_signature(self.runner, self.context, self.jobs) self.script.write_text('JOB = "MESH-EXTENDED-AUTHORING"\n') added = input_signature(self.runner, self.context, self.jobs) self.assertNotEqual(before, added) self.script.write_text(self.script.read_text() + "# changed\n") self.assertNotEqual(added, input_signature(self.runner, self.context, self.jobs)) def test_declared_dependency_change_wakes_and_retries(self): self.script.write_text(self.script.read_text() + 'PREPARATION_INPUTS = ["fixture.json"]\n') dependency = self.root / "fixture.json" dependency.write_text("1") self.runner.run_process.return_value["exitCode"] = 1 self.prepare() before = input_signature(self.runner, self.context, self.jobs) dependency.write_text("2") self.assertNotEqual(before, input_signature(self.runner, self.context, self.jobs)) self.prepare() self.assertEqual(2, self.runner.run_process.call_count) def test_existing_contract_is_never_overwritten(self): path = self.root / "my-tools/local-workflows/MESH-EXTENDED-AUTHORING.json" path.parent.mkdir() path.write_text('{"status":"being-edited"}') self.assertEqual([], self.prepare()) self.runner.run_process.assert_not_called() def test_invalid_generated_contract_is_not_prepared(self): self.admission.return_value = None, [{"reason": "parent mismatch"}] self.assertEqual([], self.prepare()) self.prepare() self.assertEqual(1, self.runner.run_process.call_count) record = json.loads(next((self.runner.directory / "preparation-attempts").glob("*.json")).read_text()) self.assertEqual("blocked", record["state"]) self.assertGreaterEqual(record["seconds"], 0) def real_recipe(self, payload=None): output = f"my-tools/local-workflows/{self.jobs[0]['id']}.json" value = "Path('recipe-input.json').read_text()" if payload is None else repr(payload) self.script.write_text( f"JOB = {self.jobs[0]['id']!r}\n" "PREPARATION_INPUTS = ['recipe-input.json']\n" "from pathlib import Path\n" f"output = Path({output!r})\n" "output.parent.mkdir(parents=True, exist_ok=True)\n" "assert not output.exists(), 'existing output must be protected'\n" f"output.write_text({value})\n" ) return self.root / output def use_real_preparation(self): self.admission_patch.stop() def execute(argv, cwd, log, timeout, **kwargs): proc = subprocess.run(argv, cwd=cwd, capture_output=True, timeout=timeout) log.parent.mkdir(parents=True, exist_ok=True) log.write_bytes(proc.stdout + proc.stderr) return {"exitCode": proc.returncode, "interrupted": False, "timedOut": False, "logLimit": False} self.runner.run_process.side_effect = execute def valid_preparation_payload(self): return json.dumps({ "task": self.context["task"], "job": self.jobs[0]["id"], "parentSha256": self.context["parentSha256"], "result": { "status": "ready", "workflow": "Local preparation admission fixture", "artifacts": [], "changedFiles": [], "dependencies": [], "routeIds": [], "sourceReferences": [], "sourceUnits": [], "checks": [{"id": "focused"}], "cases": [{"id": "positive"}], }, }) def test_owned_invalid_json_retries_after_recipe_repair(self): self.use_real_preparation() origin = self.real_recipe("{") self.assertEqual([], self.prepare()) record_path = self.runner.directory / "preparation-attempts" / f"{self.jobs[0]['id']}.json" record = json.loads(record_path.read_text()) self.assertEqual("blocked", record["state"]) self.assertEqual({"path": str(origin.relative_to(self.root)), "sha256": hashlib.sha256(origin.read_bytes()).hexdigest()}, record["generatedOrigin"]) self.assertEqual([], self.prepare()) self.assertEqual(1, self.runner.run_process.call_count) payload = self.valid_preparation_payload() self.real_recipe(payload) self.assertEqual([self.jobs[0]["id"]], self.prepare()) self.assertEqual(payload, origin.read_text()) self.assertEqual(2, self.runner.run_process.call_count) self.assertEqual("prepared", json.loads(record_path.read_text())["state"]) self.assertEqual([], self.prepare()) self.assertEqual(2, self.runner.run_process.call_count) def test_owned_invalid_json_retries_after_declared_input_repair(self): self.use_real_preparation() dependency = self.root / "recipe-input.json" dependency.write_text("{") origin = self.real_recipe() self.assertEqual([], self.prepare()) self.assertEqual([], self.prepare()) self.assertEqual(1, self.runner.run_process.call_count) dependency.write_text(self.valid_preparation_payload()) self.assertEqual([self.jobs[0]["id"]], self.prepare()) self.assertEqual(dependency.read_text(), origin.read_text()) self.assertEqual(2, self.runner.run_process.call_count) def test_owned_failed_output_edited_by_user_is_not_overwritten(self): self.use_real_preparation() origin = self.real_recipe("{") self.assertEqual([], self.prepare()) origin.write_text('{"manual": true}') self.real_recipe(self.valid_preparation_payload()) self.assertEqual([], self.prepare()) self.assertEqual('{"manual": true}', origin.read_text()) self.assertEqual(1, self.runner.run_process.call_count) def test_preexisting_output_is_protected_after_recipe_change(self): self.use_real_preparation() origin = self.real_recipe("{") origin.parent.mkdir(parents=True) origin.write_text('{"manual": true}') self.assertEqual([], self.prepare()) self.real_recipe(self.valid_preparation_payload()) self.assertEqual([], self.prepare()) self.assertEqual('{"manual": true}', origin.read_text()) self.runner.run_process.assert_not_called() def test_failed_output_without_ownership_record_is_not_overwritten(self): self.use_real_preparation() origin = self.real_recipe("{") self.assertEqual([], self.prepare()) record_path = self.runner.directory / "preparation-attempts" / f"{self.jobs[0]['id']}.json" record = json.loads(record_path.read_text()) del record["generatedOrigin"] record_path.write_text(json.dumps(record)) self.real_recipe(self.valid_preparation_payload()) self.assertEqual([], self.prepare()) self.assertEqual("{", origin.read_text()) self.assertEqual(1, self.runner.run_process.call_count) def test_conflicting_recipes_do_not_execute(self): (self.root / "my-tools/prepare_wbv2_duplicate_workflow.py").write_text(self.script.read_text()) self.assertEqual([], self.prepare()) self.runner.run_process.assert_not_called() def test_symlink_and_computed_job_are_not_executed(self): outside = self.root / "other.py" outside.write_text(self.script.read_text()) self.script.unlink() self.script.symlink_to(outside) self.assertEqual([], self.prepare()) self.script.unlink() self.script.write_text('JOB = "MESH" + "-EXTENDED-AUTHORING"\n') self.assertEqual([], self.prepare()) self.runner.run_process.assert_not_called() def test_authority_change_interrupts_before_publication(self): self.authority.side_effect = [self.context, {**self.context, "task": "OTHER"}] with self.assertRaises(InterruptedError): self.prepare() self.admission.assert_not_called() def test_independent_job_continues_after_preparation_failure(self): self.jobs.append({"id": "SECOND", "ownedPaths": []}) (self.root / "my-tools/prepare_wbv2_second_workflow.py").write_text('JOB = "SECOND"\n') self.runner.run_process.side_effect = [{"exitCode": 1}, {"exitCode": 0}] self.assertEqual(["SECOND"], self.prepare()) def test_preparation_is_admitted_and_verified_in_the_same_cycle(self): from test_wbv2_task_dispatch import AutoDispatchTests self.authority_patch.stop() self.admission_patch.stop() fixture = AutoDispatchTests() fixture.setUp() try: jobs = fixture.jobs(1) job = jobs[0] (fixture.root / job["resultPath"]).replace(fixture.root / "recipe-input.json") fixture.file("my-tools/prepare_wbv2_seed_workflow.py", ( f"JOB = {job['id']!r}\n" "PREPARATION_INPUTS = ['recipe-input.json']\n" "from pathlib import Path\n" "import shutil\n" "target = Path('my-tools/local-workflows') / (JOB + '.json')\n" "target.parent.mkdir(parents=True, exist_ok=True)\n" "shutil.copyfile('recipe-input.json', target)\n" )) self.assertEqual(0, fixture.run_local(1)) self.assertEqual("verified", fixture.runner.state["jobs"][job["id"]]["state"]) attempt = json.loads((fixture.runner.directory / "preparation-attempts" / f"{job['id']}.json").read_text()) self.assertEqual("prepared", attempt["state"]) finally: fixture.doCleanups() if __name__ == "__main__": unittest.main()