From 90ff81d8a7040620609a42a4e3c262b3f69127b1 Mon Sep 17 00:00:00 2001 From: jeremydixon22 Date: Mon, 10 Aug 2026 07:01:47 -0400 Subject: [PATCH] fix(python): isolate generated local applications --- PUBLIC-EXPORT-MANIFEST.json | 12 ++-- README.md | 4 +- runtimes/python/README.md | 8 ++- runtimes/python/src/vyral_runtime/_starter.py | 54 +++++++++++++-- runtimes/python/tests/test_host_cli.py | 5 ++ runtimes/python/tests/test_starter.py | 66 +++++++++++++++++++ 6 files changed, 132 insertions(+), 17 deletions(-) diff --git a/PUBLIC-EXPORT-MANIFEST.json b/PUBLIC-EXPORT-MANIFEST.json index 665dacd..a28b004 100644 --- a/PUBLIC-EXPORT-MANIFEST.json +++ b/PUBLIC-EXPORT-MANIFEST.json @@ -129,7 +129,7 @@ { "mode": "644", "path": "README.md", - "sha256": "f73e0c1859dc69be7a796253ca87d58f45ce77911ab02598cbe3fe6b0a1b9607" + "sha256": "66b54aa4b9fadd5d3126ee6b24ad84073db99a8559451ba04a10d86cc3bbe92f" }, { "mode": "644", @@ -659,7 +659,7 @@ { "mode": "644", "path": "runtimes/python/README.md", - "sha256": "38e7e9dbffba5632fdad5aab673050140d118f82baa8fc1d994721213d14ff3c" + "sha256": "8685c30e1e14015d602ed0aa78c037f8c1854262e373182e505a5033448b3ceb" }, { "mode": "644", @@ -799,7 +799,7 @@ { "mode": "644", "path": "runtimes/python/src/vyral_runtime/_starter.py", - "sha256": "cd79d15b65f4cad7ab795c7616c2fba85772133ae47e11d0c3b7e839f692be16" + "sha256": "7c628c66603796ecfc3e083221cccb9a4ba54da6739323e4cbd13b064349f5a4" }, { "mode": "644", @@ -1194,7 +1194,7 @@ { "mode": "644", "path": "runtimes/python/tests/test_host_cli.py", - "sha256": "642ca4f2f0cda08f3a80f45238f3cde6a44e5cff1e32ea56672477d70fdb41b5" + "sha256": "44ce6d00fc4253c78e2b5f2de043e8009c40623f1889811b55900075c77a5ca0" }, { "mode": "644", @@ -1269,7 +1269,7 @@ { "mode": "644", "path": "runtimes/python/tests/test_starter.py", - "sha256": "e6166b2fffad3940da2809c6fca1aee133bede29bf26445c441c4c7a825d8f1c" + "sha256": "08e042ae61608223f7be1201da65d61cc200ed11fa5c229b0d0a7ae849b3a31c" }, { "mode": "644", @@ -4089,5 +4089,5 @@ ], "schemaVersion": 1, "sourceDirty": false, - "treeSha256": "16d953977567cbb53f57e1d59b047e8da3631cce744876a6753477016ba353a1" + "treeSha256": "3c91643fa12d674d5854b57885be3f5a56fdd2ac55917ebd5e232ee2d1e1923f" } diff --git a/README.md b/README.md index b740d3f..c772956 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,9 @@ durable receipt before dispatch, closes and reopens its local runtime, and then completes the preserved run. Rerunning the file replays the same idempotent run instead of dispatching duplicate work. The generator creates one readable Python file, never overwrites an existing path, and keeps its state visibly -beside the file under `.vyral/starter`. After editing the intended work, the +beside the file under its own `.vyral/` directory (for the +example above, `.vyral/vyral_app`). Sibling generated applications receive +distinct state and durable identities. After editing the intended work, the developer increments the visible `RUN_VERSION` to admit a new idempotent run. Run the connected retrieval-and-execution quickstart against a separate, diff --git a/runtimes/python/README.md b/runtimes/python/README.md index 77e46cc..050c0fa 100644 --- a/runtimes/python/README.md +++ b/runtimes/python/README.md @@ -72,9 +72,11 @@ python ./vyral_app.py The generated file uses `@vyral(...)`, admits work with a stable idempotency key, prints the durable receipt, closes the first runtime before dispatch, -reopens `.vyral/starter`, and completes the preserved run. Running the same -file again reports `replayed=true` and dispatches no duplicate work. The -generator refuses to overwrite an existing path; the result is ordinary +reopens its own `.vyral/` directory, and completes the +preserved run. For `vyral_app.py`, that directory is `.vyral/vyral_app`. +Running the same file again reports `replayed=true` and dispatches no duplicate +work. Sibling generated files receive distinct state and durable identities. +The generator refuses to overwrite an existing path; the result is ordinary Python source intended to be edited or absorbed into an application. A visible `RUN_VERSION` makes intent explicit: leave it unchanged to prove replay, then increment it after changing the work to admit a new run. diff --git a/runtimes/python/src/vyral_runtime/_starter.py b/runtimes/python/src/vyral_runtime/_starter.py index 7765e4a..30e08d6 100644 --- a/runtimes/python/src/vyral_runtime/_starter.py +++ b/runtimes/python/src/vyral_runtime/_starter.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from hashlib import sha256 from pathlib import Path @@ -24,9 +25,13 @@ ) -STATE_ROOT = Path(__file__).resolve().parent / ".vyral" / "starter" -PLUGIN_ID = "starter" -HANDLER_ID = "starter.hello" +STATE_ROOT = ( + Path(__file__).resolve().parent + / ".vyral" + / __VYRAL_STARTER_STATE_DIRECTORY__ +) +PLUGIN_ID = __VYRAL_STARTER_PLUGIN_ID__ +HANDLER_ID = __VYRAL_STARTER_HANDLER_ID__ # Rerun unchanged to prove idempotency. Increment only to admit new work. RUN_VERSION = 1 @@ -41,7 +46,7 @@ async def hello(context: ExecutionRunContext) -> ExecutionRunResult: if isinstance(candidate, str) and candidate.strip(): name = candidate.strip() await context.record_event( - "starter.hello", + HANDLER_ID, message="The user-owned Vyral handler is running.", ) return ExecutionRunResult.succeeded_result( @@ -62,7 +67,7 @@ async def main() -> None: HANDLER_ID, plugin_id=PLUGIN_ID, payload={"name": "Vyral", "runVersion": RUN_VERSION}, - idempotency_key=f"starter.hello.v{RUN_VERSION}", + idempotency_key=f"{HANDLER_ID}.v{RUN_VERSION}", ) with VyralRuntime.open_local( @@ -109,11 +114,13 @@ async def main() -> None: class LocalStarterResult: created_path: Path state_root_path: Path + app_id: str def to_dict(self) -> dict[str, object]: return { "createdPath": str(self.created_path), "stateRootPath": str(self.state_root_path), + "appId": self.app_id, "runArguments": ["python", str(self.created_path)], } @@ -130,9 +137,20 @@ def create_local_starter(output_path: str | Path) -> LocalStarterResult: ) requested.parent.mkdir(parents=True, exist_ok=True) target = requested.absolute() + identity = _starter_identity(target.stem) + state_directory = identity.removeprefix("starter.") + handler_id = f"{identity}.hello" + source = ( + _STARTER_SOURCE.replace( + "__VYRAL_STARTER_STATE_DIRECTORY__", + repr(state_directory), + ) + .replace("__VYRAL_STARTER_PLUGIN_ID__", repr(identity)) + .replace("__VYRAL_STARTER_HANDLER_ID__", repr(handler_id)) + ) try: with target.open("x", encoding="utf-8", newline="\n") as stream: - stream.write(_STARTER_SOURCE) + stream.write(source) except FileExistsError as error: raise ValueError( f"Refusing to overwrite an existing starter path: {target}" @@ -140,8 +158,30 @@ def create_local_starter(output_path: str | Path) -> LocalStarterResult: created = target.resolve() return LocalStarterResult( created_path=created, - state_root_path=created.parent / ".vyral" / "starter", + state_root_path=created.parent / ".vyral" / state_directory, + app_id=identity, ) +def _starter_identity(stem: str) -> str: + characters: list[str] = [] + previous_separator = False + for character in stem.casefold(): + if character.isascii() and ( + character.isalnum() or character in {"_", "-"} + ): + characters.append(character) + previous_separator = False + elif not previous_separator: + characters.append("-") + previous_separator = True + normalized = "".join(characters).strip("-_") or "app" + changed = normalized != stem or len(normalized) > 80 + if changed: + digest = sha256(stem.encode("utf-8")).hexdigest()[:8] + base = normalized[:70].rstrip("-_") or "app" + normalized = f"{base}-{digest}" + return f"starter.{normalized}" + + __all__ = ["LocalStarterResult", "create_local_starter"] diff --git a/runtimes/python/tests/test_host_cli.py b/runtimes/python/tests/test_host_cli.py index d1610a6..9c9bc1c 100644 --- a/runtimes/python/tests/test_host_cli.py +++ b/runtimes/python/tests/test_host_cli.py @@ -42,6 +42,11 @@ def test_init_creates_an_editable_application_without_server_extra( self.assertEqual(0, status) result = json.loads(output.getvalue()) self.assertEqual(str(target.resolve()), result["createdPath"]) + self.assertEqual("starter.vyral_app", result["appId"]) + self.assertEqual( + str((target.parent / ".vyral" / "vyral_app").resolve()), + result["stateRootPath"], + ) self.assertEqual( ["python", str(target.resolve())], result["runArguments"], diff --git a/runtimes/python/tests/test_starter.py b/runtimes/python/tests/test_starter.py index e4ed152..ccae560 100644 --- a/runtimes/python/tests/test_starter.py +++ b/runtimes/python/tests/test_starter.py @@ -20,6 +20,11 @@ def test_generated_application_survives_restart_and_replays(self) -> None: result = create_local_starter(target) self.assertEqual(target.resolve(), result.created_path) + self.assertEqual("starter.vyral_app", result.app_id) + self.assertEqual( + (target.parent / ".vyral" / "vyral_app").resolve(), + result.state_root_path, + ) self.assertIn("@vyral(", target.read_text(encoding="utf-8")) environment = os.environ.copy() source = str(Path(__file__).resolve().parents[1] / "src") @@ -92,6 +97,67 @@ def test_generated_application_survives_restart_and_replays(self) -> None: self.assertNotEqual(first_run.group(1), versioned_run.group(1)) self.assertTrue(result.state_root_path.is_dir()) + def test_sibling_generated_apps_have_isolated_durable_identity(self) -> None: + with tempfile.TemporaryDirectory( + prefix="vyral-local-starter-siblings-" + ) as temporary: + root = Path(temporary) + first_result = create_local_starter(root / "alpha.py") + second_result = create_local_starter(root / "beta.py") + environment = os.environ.copy() + source = str(Path(__file__).resolve().parents[1] / "src") + environment["PYTHONPATH"] = os.pathsep.join( + value + for value in (source, environment.get("PYTHONPATH", "")) + if value + ) + + first = subprocess.run( + [sys.executable, str(first_result.created_path)], + check=True, + capture_output=True, + text=True, + env=environment, + timeout=30, + ) + second = subprocess.run( + [sys.executable, str(second_result.created_path)], + check=True, + capture_output=True, + text=True, + env=environment, + timeout=30, + ) + + self.assertNotEqual(first_result.app_id, second_result.app_id) + self.assertNotEqual( + first_result.state_root_path, + second_result.state_root_path, + ) + self.assertIn("status=queued replayed=false", first.stdout) + self.assertIn("status=queued replayed=false", second.stdout) + self.assertTrue(first_result.state_root_path.is_dir()) + self.assertTrue(second_result.state_root_path.is_dir()) + + def test_generated_identity_is_safe_for_unusual_filenames(self) -> None: + with tempfile.TemporaryDirectory( + prefix="vyral-local-starter-identity-" + ) as temporary: + target = Path(temporary) / "My app (draft).py" + result = create_local_starter(target) + source = target.read_text(encoding="utf-8") + + self.assertRegex( + result.app_id, + r"^starter\.my-app-draft-[0-9a-f]{8}$", + ) + self.assertEqual( + result.app_id.removeprefix("starter."), + result.state_root_path.name, + ) + self.assertIn(repr(result.app_id), source) + self.assertNotIn("__VYRAL_STARTER_", source) + def test_generator_refuses_overwrite_and_non_python_paths(self) -> None: with tempfile.TemporaryDirectory( prefix="vyral-local-starter-boundary-"