From 6d336c5c46313ded111b146c193624b99bfc8d98 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Fri, 5 Jun 2026 20:06:07 +0300 Subject: [PATCH] Wire Hyperprompt compile into Timeweb deploy --- README.md | 3 + docs/deployment.md | 22 +++- scripts/platform.py | 220 +++++++++++++++++++++++++++++++++- tests/test_platform_deploy.py | 77 ++++++++++++ 4 files changed, 320 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7404959..c89da06 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,9 @@ interpolation. service-producing CI job. The lock carries digest-pinned image refs for `specspace_api` and `specspace_ui`, letting Platform render one composite deploy manifest without storing Timeweb secrets or rebuilding service images. +The Timeweb renderer also enables SpecSpace HTTP-provider Hyperprompt compile +with a `/tmp` scratch workspace and bounded runtime limits; use +`--disable-hyperprompt-http-compile` for a manifest-level rollback. The GitHub Actions workflow `Timeweb Publish` is the production Timeweb deploy publisher. SpecSpace CI produces the service image lock and triggers this workflow; Platform renders, validates, and publishes the `timeweb-deploy` branch diff --git a/docs/deployment.md b/docs/deployment.md index ccfa682..48d9c21 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -153,6 +153,24 @@ their environment variables, override image lock values. This keeps local operator checks possible while preserving one Platform-owned renderer for the production Timeweb tree. +The Timeweb renderer enables SpecSpace HTTP-provider Hyperprompt compile by +default. The rendered API service keeps SpecGraph artifacts read-only and passes +a scratch workspace plus bounded runtime limits to SpecSpace: + +```text +SPECSPACE_HYPERPROMPT_HTTP_COMPILE_ENABLED=true +SPECSPACE_HYPERPROMPT_WORK_DIR=/tmp +SPECSPACE_HYPERPROMPT_COMPILE_TIMEOUT_SECONDS=60 +SPECSPACE_HYPERPROMPT_MAX_INPUT_BYTES=1048576 +SPECSPACE_HYPERPROMPT_MAX_OUTPUT_BYTES=2097152 +SPECSPACE_HYPERPROMPT_BUNDLE_RETENTION_COUNT=20 +``` + +Use `--disable-hyperprompt-http-compile` for an emergency rollback without +changing service images. The same values can be overridden through the matching +`SPECSPACE_HYPERPROMPT_*` environment variables or the `deploy timeweb-render` +flags. + The rendered tree contains only: - `docker-compose.yml`; @@ -168,7 +186,9 @@ Guardrails: - no bind mounts or named volumes; - no required `${VAR:?message}` interpolation; - SpecSpace API must read SpecGraph artifacts through `--artifact-base-url`; -- SpecSpace API must read SpecPM metadata through `--specpm-registry-url`. +- SpecSpace API must read SpecPM metadata through `--specpm-registry-url`; +- SpecSpace API must carry the expected Hyperprompt HTTP compile flag and + limits. ## Timeweb Production Control Plane diff --git a/scripts/platform.py b/scripts/platform.py index 0186d09..024f615 100755 --- a/scripts/platform.py +++ b/scripts/platform.py @@ -26,6 +26,11 @@ SPECGRAPH_SUPERVISOR_REL = Path("tools") / "supervisor.py" PROJECT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") INIT_TIMEOUT_SECONDS = 120 +DEFAULT_TIMEWEB_HYPERPROMPT_WORK_DIR = "/tmp" +DEFAULT_TIMEWEB_HYPERPROMPT_COMPILE_TIMEOUT_SECONDS = "60" +DEFAULT_TIMEWEB_HYPERPROMPT_MAX_INPUT_BYTES = "1048576" +DEFAULT_TIMEWEB_HYPERPROMPT_MAX_OUTPUT_BYTES = "2097152" +DEFAULT_TIMEWEB_HYPERPROMPT_BUNDLE_RETENTION_COUNT = "20" DIGEST_IMAGE_RE = re.compile( r"^[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._-]+)?@sha256:[0-9a-f]{64}$" ) @@ -76,6 +81,16 @@ class TimewebImageRefs: image_lock: Path | None = None +@dataclass(frozen=True) +class TimewebHyperpromptRuntime: + http_compile_enabled: bool + work_dir: str + compile_timeout_seconds: str + max_input_bytes: str + max_output_bytes: str + bundle_retention_count: str + + def load_yaml(path: Path) -> dict[str, Any]: try: import yaml @@ -1302,6 +1317,67 @@ def safe_output_dir(path: Path) -> None: raise PlatformError(f"refusing unsafe output directory: {path}") +def bool_from_env(name: str, *, default: bool) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def positive_int_string(value: str, *, label: str) -> str: + try: + parsed = int(value) + except ValueError as exc: + raise PlatformError(f"{label} must be a positive integer, got {value!r}") from exc + if parsed < 1: + raise PlatformError(f"{label} must be a positive integer, got {value!r}") + return str(parsed) + + +def timeweb_hyperprompt_runtime_from_args(args: argparse.Namespace) -> TimewebHyperpromptRuntime: + work_dir = str(args.hyperprompt_work_dir) + if args.hyperprompt_http_compile_enabled and not work_dir: + raise PlatformError("Hyperprompt work dir must be set when HTTP compile is enabled") + return TimewebHyperpromptRuntime( + http_compile_enabled=bool(args.hyperprompt_http_compile_enabled), + work_dir=work_dir, + compile_timeout_seconds=positive_int_string( + str(args.hyperprompt_compile_timeout_seconds), + label="Hyperprompt compile timeout seconds", + ), + max_input_bytes=positive_int_string( + str(args.hyperprompt_max_input_bytes), + label="Hyperprompt max input bytes", + ), + max_output_bytes=positive_int_string( + str(args.hyperprompt_max_output_bytes), + label="Hyperprompt max output bytes", + ), + bundle_retention_count=positive_int_string( + str(args.hyperprompt_bundle_retention_count), + label="Hyperprompt bundle retention count", + ), + ) + + +def render_timeweb_hyperprompt_environment(runtime: TimewebHyperpromptRuntime) -> str: + lines = [ + f" SPECSPACE_HYPERPROMPT_HTTP_COMPILE_ENABLED: " + f"\"{str(runtime.http_compile_enabled).lower()}\"\n", + ] + if runtime.http_compile_enabled: + lines += [ + f" SPECSPACE_HYPERPROMPT_WORK_DIR: \"{runtime.work_dir}\"\n", + f" SPECSPACE_HYPERPROMPT_COMPILE_TIMEOUT_SECONDS: " + f"\"{runtime.compile_timeout_seconds}\"\n", + f" SPECSPACE_HYPERPROMPT_MAX_INPUT_BYTES: \"{runtime.max_input_bytes}\"\n", + f" SPECSPACE_HYPERPROMPT_MAX_OUTPUT_BYTES: \"{runtime.max_output_bytes}\"\n", + f" SPECSPACE_HYPERPROMPT_BUNDLE_RETENTION_COUNT: " + f"\"{runtime.bundle_retention_count}\"\n", + ] + return "".join(lines) + + def render_timeweb_compose( *, api_image_ref: str, @@ -1309,6 +1385,7 @@ def render_timeweb_compose( artifact_base_url: str, specpm_registry_url: str, release_commit: str, + hyperprompt_runtime: TimewebHyperpromptRuntime, ) -> str: return ( "name: specspace\n\n" @@ -1325,6 +1402,7 @@ def render_timeweb_compose( f" SPECSPACE_API_IMAGE_REF: \"{api_image_ref}\"\n" f" SPECSPACE_UI_IMAGE_REF: \"{ui_image_ref}\"\n" f" SPECSPACE_RELEASE_COMMIT: \"{release_commit}\"\n" + f"{render_timeweb_hyperprompt_environment(hyperprompt_runtime)}" " command:\n" " - python\n" " - viewer/server.py\n" @@ -1345,6 +1423,7 @@ def render_timeweb_compose( def write_timeweb_manifest(args: argparse.Namespace) -> TimewebManifest: image_refs = resolve_timeweb_image_refs(args) + hyperprompt_runtime = timeweb_hyperprompt_runtime_from_args(args) output_dir = Path(args.output_dir) safe_output_dir(output_dir) @@ -1370,6 +1449,7 @@ def write_timeweb_manifest(args: argparse.Namespace) -> TimewebManifest: artifact_base_url=args.artifact_base_url, specpm_registry_url=args.specpm_registry_url, release_commit=release_commit, + hyperprompt_runtime=hyperprompt_runtime, ), encoding="utf-8", ) @@ -1385,7 +1465,11 @@ def write_timeweb_manifest(args: argparse.Namespace) -> TimewebManifest: f"- UI image: `{image_refs.specspace_ui_image_ref}`\n" f"- Image lock: `{image_refs.image_lock or '(not used)'}`\n" f"- SpecGraph artifact source: `{args.artifact_base_url}`\n" - f"- SpecPM registry source: `{args.specpm_registry_url}`\n\n" + f"- SpecPM registry source: `{args.specpm_registry_url}`\n" + f"- HTTP Hyperprompt compile: " + f"`{'enabled' if hyperprompt_runtime.http_compile_enabled else 'disabled'}`\n" + f"- Hyperprompt scratch workspace: " + f"`{hyperprompt_runtime.work_dir if hyperprompt_runtime.http_compile_enabled else '(not used)'}`\n\n" "## Notes\n\n" "- The first service is named `app` because Timeweb proxies the public " "domain to the first compose service.\n" @@ -1406,6 +1490,22 @@ def write_timeweb_manifest(args: argparse.Namespace) -> TimewebManifest: "specspace_api_image_ref": image_refs.specspace_api_image_ref, "specspace_ui_image_ref": image_refs.specspace_ui_image_ref, "artifact_base_url": args.artifact_base_url, + "hyperprompt_http_compile_enabled": ( + hyperprompt_runtime.http_compile_enabled + ), + "hyperprompt_work_dir": ( + hyperprompt_runtime.work_dir + if hyperprompt_runtime.http_compile_enabled + else None + ), + "hyperprompt_compile_timeout_seconds": ( + hyperprompt_runtime.compile_timeout_seconds + ), + "hyperprompt_max_input_bytes": hyperprompt_runtime.max_input_bytes, + "hyperprompt_max_output_bytes": hyperprompt_runtime.max_output_bytes, + "hyperprompt_bundle_retention_count": ( + hyperprompt_runtime.bundle_retention_count + ), "specpm_registry_url": args.specpm_registry_url, }, indent=2, @@ -1463,6 +1563,23 @@ def command_for_service(blocks: dict[str, list[str]], service_name: str) -> list return values +def environment_for_service(blocks: dict[str, list[str]], service_name: str) -> dict[str, str]: + values: dict[str, str] = {} + in_environment = False + for line in blocks.get(service_name, []): + if re.match(r"^ environment:\s*$", line): + in_environment = True + continue + if in_environment: + match = re.match(r"^ ([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$", line) + if match: + values[match.group(1)] = match.group(2).strip().strip('"').strip("'") + continue + if line.strip() and not line.startswith(" "): + break + return values + + def command_value_after(command: list[str], flag: str) -> str | None: try: index = command.index(flag) @@ -1486,6 +1603,7 @@ def validate_timeweb_manifest_tree( *, artifact_base_url: str, specpm_registry_url: str, + hyperprompt_runtime: TimewebHyperpromptRuntime, ) -> list[str]: target_file = "docker-compose.yml" compose_path = root / target_file @@ -1546,6 +1664,37 @@ def validate_timeweb_manifest_tree( f"{specpm_registry_url}, got {actual_specpm_registry_url}" ) + api_environment = environment_for_service(blocks, "specspace-api") + expected_compile_enabled = str(hyperprompt_runtime.http_compile_enabled).lower() + actual_compile_enabled = api_environment.get( + "SPECSPACE_HYPERPROMPT_HTTP_COMPILE_ENABLED" + ) + if actual_compile_enabled != expected_compile_enabled: + errors.append( + f"{target_file} specspace-api environment must set " + "SPECSPACE_HYPERPROMPT_HTTP_COMPILE_ENABLED to " + f"{expected_compile_enabled}, got {actual_compile_enabled!r}" + ) + if hyperprompt_runtime.http_compile_enabled: + expected_hyperprompt_environment = { + "SPECSPACE_HYPERPROMPT_WORK_DIR": hyperprompt_runtime.work_dir, + "SPECSPACE_HYPERPROMPT_COMPILE_TIMEOUT_SECONDS": ( + hyperprompt_runtime.compile_timeout_seconds + ), + "SPECSPACE_HYPERPROMPT_MAX_INPUT_BYTES": hyperprompt_runtime.max_input_bytes, + "SPECSPACE_HYPERPROMPT_MAX_OUTPUT_BYTES": hyperprompt_runtime.max_output_bytes, + "SPECSPACE_HYPERPROMPT_BUNDLE_RETENTION_COUNT": ( + hyperprompt_runtime.bundle_retention_count + ), + } + for key, expected in expected_hyperprompt_environment.items(): + actual = api_environment.get(key) + if actual != expected: + errors.append( + f"{target_file} specspace-api environment must set {key} " + f"to {expected}, got {actual!r}" + ) + for service_name in ("app", "specspace-api"): image = image_for_service(blocks, service_name) if image is None: @@ -1560,10 +1709,12 @@ def validate_timeweb_manifest_tree( def deploy_timeweb_render(args: argparse.Namespace) -> int: manifest = write_timeweb_manifest(args) + hyperprompt_runtime = timeweb_hyperprompt_runtime_from_args(args) errors = validate_timeweb_manifest_tree( manifest.output_dir, artifact_base_url=args.artifact_base_url, specpm_registry_url=args.specpm_registry_url, + hyperprompt_runtime=hyperprompt_runtime, ) if errors: raise PlatformError("generated Timeweb manifest is invalid: " + "; ".join(errors)) @@ -1586,10 +1737,12 @@ def deploy_timeweb_render(args: argparse.Namespace) -> int: def deploy_timeweb_validate(args: argparse.Namespace) -> int: root = Path(args.path) + hyperprompt_runtime = timeweb_hyperprompt_runtime_from_args(args) errors = validate_timeweb_manifest_tree( root, artifact_base_url=args.artifact_base_url, specpm_registry_url=args.specpm_registry_url, + hyperprompt_runtime=hyperprompt_runtime, ) payload = { "action": "timeweb-validate", @@ -1841,6 +1994,69 @@ def add_deploy_common(command_parser: argparse.ArgumentParser) -> None: ) bundle_parser.set_defaults(func=deploy_bundle, env_file=None) + def add_timeweb_hyperprompt_args(command_parser: argparse.ArgumentParser) -> None: + command_parser.set_defaults( + hyperprompt_http_compile_enabled=bool_from_env( + "SPECSPACE_HYPERPROMPT_HTTP_COMPILE_ENABLED", + default=True, + ) + ) + command_parser.add_argument( + "--enable-hyperprompt-http-compile", + dest="hyperprompt_http_compile_enabled", + action="store_true", + help=( + "Render SpecSpace HTTP-provider Hyperprompt compile settings " + "(default for Timeweb production)." + ), + ) + command_parser.add_argument( + "--disable-hyperprompt-http-compile", + dest="hyperprompt_http_compile_enabled", + action="store_false", + help="Render SpecSpace HTTP-provider Hyperprompt compile as disabled.", + ) + command_parser.add_argument( + "--hyperprompt-work-dir", + default=os.environ.get( + "SPECSPACE_HYPERPROMPT_WORK_DIR", + DEFAULT_TIMEWEB_HYPERPROMPT_WORK_DIR, + ), + help="Writable scratch directory for SpecSpace Hyperprompt compile.", + ) + command_parser.add_argument( + "--hyperprompt-compile-timeout-seconds", + default=os.environ.get( + "SPECSPACE_HYPERPROMPT_COMPILE_TIMEOUT_SECONDS", + DEFAULT_TIMEWEB_HYPERPROMPT_COMPILE_TIMEOUT_SECONDS, + ), + help="SpecSpace Hyperprompt compile subprocess timeout.", + ) + command_parser.add_argument( + "--hyperprompt-max-input-bytes", + default=os.environ.get( + "SPECSPACE_HYPERPROMPT_MAX_INPUT_BYTES", + DEFAULT_TIMEWEB_HYPERPROMPT_MAX_INPUT_BYTES, + ), + help="Maximum generated Markdown input bytes accepted by SpecSpace.", + ) + command_parser.add_argument( + "--hyperprompt-max-output-bytes", + default=os.environ.get( + "SPECSPACE_HYPERPROMPT_MAX_OUTPUT_BYTES", + DEFAULT_TIMEWEB_HYPERPROMPT_MAX_OUTPUT_BYTES, + ), + help="Maximum compiled Markdown bytes returned by SpecSpace.", + ) + command_parser.add_argument( + "--hyperprompt-bundle-retention-count", + default=os.environ.get( + "SPECSPACE_HYPERPROMPT_BUNDLE_RETENTION_COUNT", + DEFAULT_TIMEWEB_HYPERPROMPT_BUNDLE_RETENTION_COUNT, + ), + help="Number of SpecSpace-owned Hyperprompt scratch bundles to retain.", + ) + timeweb_render_parser = deploy_subcommands.add_parser( "timeweb-render", help="Write a Timeweb Cloud Apps manifest-only deploy tree.", @@ -1888,6 +2104,7 @@ def add_deploy_common(command_parser: argparse.ArgumentParser) -> None: default=os.environ.get("SPECSPACE_RELEASE_CREATED_AT"), help="UTC release timestamp to embed in deployment metadata.", ) + add_timeweb_hyperprompt_args(timeweb_render_parser) timeweb_render_parser.add_argument( "--format", choices=["table", "json"], @@ -1915,6 +2132,7 @@ def add_deploy_common(command_parser: argparse.ArgumentParser) -> None: default=os.environ.get("TIMEWEB_REQUIRED_SPECPM_REGISTRY_URL", "https://specpm.dev"), help="Required readonly SpecPM registry URL.", ) + add_timeweb_hyperprompt_args(timeweb_validate_parser) timeweb_validate_parser.add_argument( "--format", choices=["table", "json"], diff --git a/tests/test_platform_deploy.py b/tests/test_platform_deploy.py index 7a9c2c1..1426947 100644 --- a/tests/test_platform_deploy.py +++ b/tests/test_platform_deploy.py @@ -290,8 +290,26 @@ def test_timeweb_render_writes_manifest_only_tree(self) -> None: self.assertNotIn("volumes:", compose) self.assertNotIn("build:", compose) self.assertNotIn("${ORG_ROOT", compose) + self.assertIn( + 'SPECSPACE_HYPERPROMPT_HTTP_COMPILE_ENABLED: "true"', + compose, + ) + self.assertIn('SPECSPACE_HYPERPROMPT_WORK_DIR: "/tmp"', compose) + self.assertIn( + 'SPECSPACE_HYPERPROMPT_COMPILE_TIMEOUT_SECONDS: "60"', + compose, + ) + self.assertIn('SPECSPACE_HYPERPROMPT_MAX_INPUT_BYTES: "1048576"', compose) + self.assertIn('SPECSPACE_HYPERPROMPT_MAX_OUTPUT_BYTES: "2097152"', compose) + self.assertIn( + 'SPECSPACE_HYPERPROMPT_BUNDLE_RETENTION_COUNT: "20"', + compose, + ) self.assertEqual(manifest["artifact_kind"], "platform_timeweb_deploy_manifest") self.assertEqual(manifest["release_commit"], "abc123") + self.assertTrue(manifest["hyperprompt_http_compile_enabled"]) + self.assertEqual(manifest["hyperprompt_work_dir"], "/tmp") + self.assertEqual(manifest["hyperprompt_compile_timeout_seconds"], "60") def test_timeweb_render_rejects_mutable_image_ref(self) -> None: with tempfile.TemporaryDirectory() as root: @@ -353,6 +371,65 @@ def test_timeweb_render_reads_service_image_lock(self) -> None: self.assertEqual(manifest["specspace_api_image_ref"], API_IMAGE) self.assertEqual(manifest["specspace_ui_image_ref"], UI_IMAGE) + def test_timeweb_render_can_disable_hyperprompt_http_compile(self) -> None: + with tempfile.TemporaryDirectory() as root: + output_dir = Path(root) / "timeweb" + render = self.run_cli( + "deploy", + "timeweb-render", + "--output-dir", + str(output_dir), + "--specspace-api-image-ref", + API_IMAGE, + "--specspace-ui-image-ref", + UI_IMAGE, + "--disable-hyperprompt-http-compile", + ) + result = self.run_cli( + "deploy", + "timeweb-validate", + "--path", + str(output_dir), + "--disable-hyperprompt-http-compile", + "--format", + "json", + ) + + self.assertEqual(render.returncode, 0, render.stderr) + self.assertEqual(result.returncode, 0, result.stderr) + compose = (output_dir / "docker-compose.yml").read_text(encoding="utf-8") + manifest = json.loads( + (output_dir / "platform-timeweb-deploy.json").read_text( + encoding="utf-8" + ) + ) + self.assertIn( + 'SPECSPACE_HYPERPROMPT_HTTP_COMPILE_ENABLED: "false"', + compose, + ) + self.assertNotIn("SPECSPACE_HYPERPROMPT_WORK_DIR", compose) + self.assertFalse(manifest["hyperprompt_http_compile_enabled"]) + self.assertIsNone(manifest["hyperprompt_work_dir"]) + self.assertTrue(json.loads(result.stdout)["valid"]) + + def test_timeweb_render_rejects_invalid_hyperprompt_limit(self) -> None: + with tempfile.TemporaryDirectory() as root: + result = self.run_cli( + "deploy", + "timeweb-render", + "--output-dir", + str(Path(root) / "timeweb"), + "--specspace-api-image-ref", + API_IMAGE, + "--specspace-ui-image-ref", + UI_IMAGE, + "--hyperprompt-max-output-bytes", + "0", + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("Hyperprompt max output bytes must be a positive integer", result.stderr) + def test_timeweb_render_validates_image_lock_refs(self) -> None: with tempfile.TemporaryDirectory() as root: root_path = Path(root)