diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 69a768b..aa4d444 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -82,9 +82,11 @@ synthetic tests now exist; see `docs/research/lightburn-project-mopa-import.md`. It preserves both process element types and requires an explicit unit contract for normalization. The bridge binds source layer IDs, uses the host MOPA setters, serializes review -metadata and disables every staged step. It never authorizes encoding. The -next import task is UI/preflight presentation plus independently established -units; absent pulse width and image equivalence remain blockers. +metadata and disables every staged step. Document preflight presents those +blockers and the offline driver rejects them before buffer preparation. It +never authorizes encoding. The next import task is GTK presentation plus an +independently established unit contract; absent pulse width and image +equivalence remain blockers. If a Windows artifact is available, keep the machine disconnected and run, in order: diff --git a/docs/research/lightburn-project-mopa-import.md b/docs/research/lightburn-project-mopa-import.md index df31071..e7d455d 100644 --- a/docs/research/lightburn-project-mopa-import.md +++ b/docs/research/lightburn-project-mopa-import.md @@ -35,6 +35,12 @@ machine, lens, correction and parameter limits before a separate reviewed promotion path can exist. Rayforge's relevant Step and LaserStep files are now included in the pinned upstream contract manifest. +The addon now also exposes a transport-free document preflight report with +stable issue codes and human-readable blocker messages. The offline driver +runs this check before it prepares encoded buffers. A staged import is rejected; +manually re-enabling its step adds a distinct error, and malformed review data +fails closed. There is intentionally no accepted or promoted import state yet. + Validation covers unit conversion, absent fields, case sensitivity, duplicate values, invalid numbers, image modes, mixed layers, exact layer binding, transactional rollback and malformed XML. Existing packaging, no-device and diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/import_preflight.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/import_preflight.py new file mode 100644 index 0000000..2f781c6 --- /dev/null +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/import_preflight.py @@ -0,0 +1,79 @@ +"""Present and enforce unresolved PirateRay workflow import blockers.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +from .workflow_import import METADATA_KEY + + +class ImportPreflightError(RuntimeError): + """A staged import is not eligible for job execution.""" + + +def _walk(items: Iterable[Any]): + for item in items: + yield item + children = getattr(item, "children", ()) + if children: + yield from _walk(children) + + +def inspect_imported_workflow(doc: Any) -> dict[str, Any]: + """Return stable, UI-ready errors for every staged import in a document.""" + issues = [] + items = getattr(doc, "children", None) + if items is None: + issues.append({ + "code": "IMPORT_DOCUMENT_INVALID", + "severity": "error", + "message": "Document tree is unavailable for PirateRay preflight.", + }) + return {"ready": False, "issues": issues} + + for step in _walk(items): + extra = getattr(step, "extra", None) + if not isinstance(extra, Mapping) or METADATA_KEY not in extra: + continue + record = extra[METADATA_KEY] + name = getattr(step, "name", "Imported workflow step") + if not isinstance(record, Mapping): + issues.append({ + "code": "IMPORT_METADATA_INVALID", "severity": "error", + "step": name, + "message": "PirateRay import review data is malformed.", + }) + continue + layer_id = record.get("source_layer_id") + if getattr(step, "visible", False): + issues.append({ + "code": "IMPORT_OUTPUT_REENABLED", "severity": "error", + "step": name, "source_layer_id": layer_id, + "message": "Imported output was re-enabled before review completed.", + }) + blockers = record.get("blockers") + if not isinstance(blockers, list) or not blockers or not all( + isinstance(value, str) and value for value in blockers + ): + issues.append({ + "code": "IMPORT_METADATA_INVALID", "severity": "error", + "step": name, "source_layer_id": layer_id, + "message": "PirateRay import blockers are missing or malformed.", + }) + continue + issues.append({ + "code": "IMPORT_REVIEW_REQUIRED", "severity": "error", + "step": name, "source_layer_id": layer_id, + "message": "Imported MOPA settings require review: " + "; ".join(blockers), + "blockers": list(blockers), + }) + return {"ready": not issues, "issues": issues} + + +def assert_no_unresolved_imports(doc: Any) -> None: + """Fail before an encoded job reaches even the offline session.""" + report = inspect_imported_workflow(doc) + if not report["ready"]: + codes = ", ".join(issue["code"] for issue in report["issues"]) + raise ImportPreflightError(f"PirateRay import preflight failed: {codes}") diff --git a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py index 8206516..186e218 100644 --- a/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py +++ b/spikes/rayforge-jcz-no-device-addon/pirate_ray_jcz/worker.py @@ -23,6 +23,7 @@ from .offline_session import ( OfflineJCZSession, OfflineSessionState, ) +from .import_preflight import assert_no_unresolved_imports from .rayforge_compat import register_driver, unregister_driver @@ -312,7 +313,8 @@ class PirateRayJCZNoDeviceDriver(NoDeviceDriver): async def run( self, encoded, doc, ops, on_command_done=None ) -> None: - del doc, ops + del ops + assert_no_unresolved_imports(doc) self.offline_session.prepare(encoded) self.offline_session.start() op_count = encoded.op_map.op_count if encoded.op_map else 0 diff --git a/tests/fixtures/rayforge-addon-spike.json b/tests/fixtures/rayforge-addon-spike.json index d9a407c..ecf5380 100644 --- a/tests/fixtures/rayforge-addon-spike.json +++ b/tests/fixtures/rayforge-addon-spike.json @@ -17,6 +17,7 @@ ], "packaged_files": [ "pirate_ray_jcz/__init__.py", + "pirate_ray_jcz/import_preflight.py", "pirate_ray_jcz/offline_session.py", "pirate_ray_jcz/profile_import.py", "pirate_ray_jcz/project_import.py", diff --git a/tests/test_lightburn_import_preflight.py b/tests/test_lightburn_import_preflight.py new file mode 100644 index 0000000..ce9d780 --- /dev/null +++ b/tests/test_lightburn_import_preflight.py @@ -0,0 +1,75 @@ +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace + +ROOT = Path(__file__).parents[1] +ADDON = ROOT / "spikes" / "rayforge-jcz-no-device-addon" +sys.path.insert(0, str(ADDON)) +from pirate_ray_jcz.import_preflight import ( + ImportPreflightError, assert_no_unresolved_imports, inspect_imported_workflow, +) +from pirate_ray_jcz.workflow_import import METADATA_KEY + + +def document(*steps): + return SimpleNamespace(children=[SimpleNamespace(children=list(steps))]) + + +def imported_step(*, visible=False, record=None): + if record is None: + record = { + "status": "staged-review-required", + "source_layer_id": "3", + "blockers": ["machine identity unverified", "unit contract unresolved"], + "encoding_allowed": False, + "hardware_use_allowed": False, + } + return SimpleNamespace(name="Imported contour", visible=visible, + extra={METADATA_KEY: record}, children=[]) + + +class ImportPreflightTests(unittest.TestCase): + def test_plain_document_is_ready(self): + report = inspect_imported_workflow(document( + SimpleNamespace(name="ordinary", visible=True, extra={}, children=[]))) + self.assertEqual(report, {"ready": True, "issues": []}) + assert_no_unresolved_imports(document()) + + def test_staged_import_is_presented_and_execution_is_rejected(self): + doc = document(imported_step()) + report = inspect_imported_workflow(doc) + self.assertFalse(report["ready"]) + self.assertEqual(["IMPORT_REVIEW_REQUIRED"], + [issue["code"] for issue in report["issues"]]) + self.assertIn("unit contract unresolved", report["issues"][0]["message"]) + with self.assertRaisesRegex(ImportPreflightError, "IMPORT_REVIEW_REQUIRED"): + assert_no_unresolved_imports(doc) + + def test_manual_reenable_adds_a_distinct_error(self): + report = inspect_imported_workflow(document(imported_step(visible=True))) + self.assertEqual( + ["IMPORT_OUTPUT_REENABLED", "IMPORT_REVIEW_REQUIRED"], + [issue["code"] for issue in report["issues"]], + ) + + def test_malformed_metadata_and_document_fail_closed(self): + for doc in ( + object(), + document(imported_step(record="bad")), + document(imported_step(record={"source_layer_id": "3", "blockers": []})), + ): + with self.subTest(doc=doc): + report = inspect_imported_workflow(doc) + self.assertFalse(report["ready"]) + self.assertTrue(any("INVALID" in issue["code"] for issue in report["issues"])) + + def test_offline_driver_runs_preflight_before_session_prepare(self): + source = (ADDON / "pirate_ray_jcz" / "worker.py").read_text(encoding="utf-8") + preflight = source.index("assert_no_unresolved_imports(doc)") + prepare = source.index("self.offline_session.prepare(encoded)") + self.assertLess(preflight, prepare) + + +if __name__ == "__main__": + unittest.main()