From f8314e7de14ebdbf1735e7d2d5743b72852f8a2b Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 19 Sep 2026 14:33:15 -0400 Subject: [PATCH] Update Healer Man server --- DEPLOYMENT.md | 20 +- PLAYTEST_NOTES.md | 44 ++++ RELEASE_RUNBOOK.md | 30 ++- docs/BALANCE_LAB.md | 9 + package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- scripts/balance_lab.py | 24 +- scripts/release_manager.py | 399 +++++++++++++++++++++++++------ scripts/release_manager_test.py | 26 ++ scripts/test_balance_lab.py | 6 + server/balance-lab-jobs.mjs | 13 +- server/balance-lab-jobs.test.mjs | 43 ++++ vite.config.ts | 13 +- 14 files changed, 529 insertions(+), 108 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index bf9d1552..296586a2 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -199,6 +199,13 @@ job returns as failed after restart, while any job with a complete unless the server has enough spare CPU for multiple full combat sweeps. All Balance Lab routes independently require a server-authorized GM account. +Balance Lab's one-shot `vite-node` workers run in the dedicated `balance-lab` +Vite mode, which disables the public directory and ignores all filesystem-watch +paths. This is required for parallel sweeps: without it, concurrent workers can +exhaust Linux's file-watcher limit while scanning the shipped asset tree and +fail immediately with `ENOSPC`. Do not use a larger inotify allowance as the +primary fix; deploy the current runtime configuration. + After saving the YAML, restart the TrueNAS app and watch its startup log for a line beginning with `Python 3.`. Reopen Balance Lab; the setup panel must show **Server runtime ready** before **Run on server** becomes available. The server @@ -224,12 +231,13 @@ schema-compatible with Healer Man and must not be copied over the new one. ## Update workflow -For a code update that includes a new APK, the visual workflow in -`RELEASE_RUNBOOK.md` is recommended. For a manual release, choose the next -version first. The example below upgrades `0.1.2` to `0.1.3`; replace `1003` if -it is not greater than every prior Android `versionCode`. Then run these -commands in **Windows PowerShell** -on the development PC. Stop if any test or build fails: +The visual release manager described in `RELEASE_RUNBOOK.md` defaults to **Git ++ TrueNAS only**, which commits and pushes a server update without changing the +Android version or publishing an APK. Clear that toggle when a code update also +requires a new APK. For a manual APK release, choose the next version first. +The example below upgrades `0.1.2` to `0.1.3`; replace `1003` if it is not +greater than every prior Android `versionCode`. Then run these commands in +**Windows PowerShell** on the development PC. Stop if any test or build fails: ```powershell Set-Location F:\Projects\HealerMan diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index ef5d4b9e..cf68b170 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -3639,3 +3639,47 @@ Vite's existing large-chunk advisory remains informational. `playtest-artifacts/balance-lab/02-level-batch-config.png`, `playtest-artifacts/balance-lab/04-server-job-complete.png`, and `playtest-artifacts/balance-lab/06-mobile-analytics.png`. + +## Balance Lab parallel watcher isolation (2026-09-19) + +- A production all-WoW, every-10-level Stockades batch completed only the level + 1 and level 80 jobs. Levels 10 through 70 failed during startup before a + party ran. Every failed job recorded `ENOSPC: System limit for number of file + watchers reached` beneath Vite's watcher stack, while the Balance Lab runtime + reported zero active jobs and normal queue capacity after the failures. +- Each parallel Python worker had launched its own `vite-node` process. The + inherited Vite development configuration watched the large shipped public + asset tree, so ten simultaneous jobs exhausted the container's Linux watcher + allowance. Which two levels completed was a startup resource race, not a + class, level, boss, or combat-model failure. +- The headless bridge now launches with `--mode balance-lab`. That Vite mode + sets `publicDir: false` and ignores every watch path, eliminating hot-reload + watchers that one-shot simulations do not use. Server job failures also + translate the known watcher-limit signature into a specific GM-facing error. +- The Python controller tests, all 14 Balance Lab service tests, the complete + 84-test server suite, and the 62-test benchmark/party suite passed. A resolved + Vite configuration check returned an empty public directory and + `watch.ignored: ["**/*"]`; a real level-10 WoW catalog invocation completed + through the new mode and wrote its 72,723-byte catalog. The production build + passed, and the authenticated GM smoke flow completed a real server job, + loaded its analytics and ability breakdown, and passed desktop/mobile checks. + +## Default Git and TrueNAS release path (2026-09-19) + +- The visual release manager now starts with **Git + TrueNAS only** enabled. + This mode retains the candidate check, content/server/application tests, + content publication, production build, staged-file review, commit, + remote-main verification, and pinned TrueNAS deployment commands. +- The default path does not bump package or Android versions, request signing + credentials, run Android sync, build an APK, or publish a Gitea release. + Clearing the toggle restores the existing full APK workflow; **Publish + Current APK Only** remains available for retrying publication from an already + versioned and pushed commit. +- **Run test suites** is independently enabled by default. Clearing it skips + only the content, server, normal-application, and shelved-application test + commands. The candidate check, content publication, production build, + staged review, Git verification, and selected deployment/APK stages remain. + Both the log and final staged review identify the skipped suites. +- All 12 release-manager unit tests and Python compilation passed. The real + Windows Tk smoke launch also initialized the default-on mode and closed + cleanly. diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md index 7784ad6a..85834725 100644 --- a/RELEASE_RUNBOOK.md +++ b/RELEASE_RUNBOOK.md @@ -11,20 +11,34 @@ On the development PC, double-click `scripts\release-manager.cmd` or run: npm run release:gui ``` -The release manager suggests the next semantic patch version and a strictly -increasing Android `versionCode`, runs the complete validation suite, and -stages the release. It then shows the exact staged paths and diff summary for -approval before it commits, pushes `main`, builds the signed APK, and publishes -the Gitea release. Signing passwords and the Gitea token are passed to the -child process in memory rather than on its command line. +The release manager opens with **Git + TrueNAS only** enabled. In this default +mode it runs the server validation and production build, shows the exact staged +paths and diff summary for approval, commits, pushes `main`, and pins the +TrueNAS **Deploy** tab to the new commit. It does not change `package.json` or +`release-version.json`, run Android sync, read signing credentials, build an +APK, or create a Gitea APK release. + +Clear **Git + TrueNAS only** when the update also needs a new Android release. +APK mode suggests the next semantic version and a strictly increasing Android +`versionCode`, then performs Android sync and publishes the signed APK after the +Git push. Signing passwords and the Gitea token are passed to the child process +in memory rather than on its command line. + +**Run test suites** is also enabled by default. Clear it for a narrowly scoped +update that does not need the content, server, normal-application, or +shelved-application test suites. The manager records the skip in its log and +staged-review dialog. This toggle does not skip the isolated release-candidate +check, content publication, production build, staged whitespace/secret review, +Git push verification, or any Android stages selected by APK mode. If a content, server, normal-application, or shelved-application test command fails, the visual manager pauses with the failed command, exit code, and recent test output. **Stop Release** remains the safe default, but **Continue Anyway** allows an intentional override and records that decision in the release log. Build, content-publication, Git, signing, and APK-publication failures are not -overridable and still stop the workflow. The manual procedure below remains -fail-closed. +overridable and still stop the workflow. Android sync, signing, and APK +publication are omitted entirely in Git + TrueNAS mode. The manual procedure +below remains fail-closed. Before changing versions or running the longer test/build sequence, the manager runs an isolated release-candidate check. It validates tracked and untracked diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index 71cadd86..145fa0bd 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -152,6 +152,15 @@ arguments are passed as an array without a shell, artifact names are allow-listed, and every job and artifact endpoint authenticates the account's server-issued GM role. +Each Python worker starts the TypeScript bridge with `vite-node --mode +balance-lab`. That dedicated mode disables Vite's public directory and ignores +all filesystem-watch paths because a headless, one-shot simulation does not +need hot reload. Keep this isolation in place when changing `vite.config.ts`: +parallel workers must not consume one watcher per shipped asset. If an older +deployment exhausts the host watcher limit, the job now reports that specific +startup failure instead of only the subprocess exit code; deploy the no-watch +runtime fix rather than increasing the host's watcher allowance. + ## Repeatable command-line runs List exact party build IDs at a level: diff --git a/package-lock.json b/package-lock.json index 24e039bc..52ecaac5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.56", + "version": "0.1.57", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.56", + "version": "0.1.57", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 7161e20a..858e3cf7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.56", + "version": "0.1.57", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 201321ba..36905190 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.56", - "versionCode": 1056 + "versionName": "0.1.57", + "versionCode": 1057 } diff --git a/scripts/balance_lab.py b/scripts/balance_lab.py index 1843f58d..a5e628ee 100644 --- a/scripts/balance_lab.py +++ b/scripts/balance_lab.py @@ -41,17 +41,23 @@ def vite_node_command() -> Path: return executable +def runtime_session_command(source: str, level: int) -> list[str]: + return [ + str(vite_node_command()), + "--mode", + "balance-lab", + str(RUNTIME), + "session", + "--source", + source, + "--level", + str(level), + ] + + class RuntimeSession: def __init__(self, source: str, level: int, workspace: Path) -> None: - command = [ - str(vite_node_command()), - str(RUNTIME), - "session", - "--source", - source, - "--level", - str(level), - ] + command = runtime_session_command(source, level) self.process = subprocess.Popen( command, cwd=ROOT, diff --git a/scripts/release_manager.py b/scripts/release_manager.py index d621492a..cc2a987e 100644 --- a/scripts/release_manager.py +++ b/scripts/release_manager.py @@ -1,4 +1,4 @@ -"""Visual release manager for Healer Man with explicit safety gates. +"""Visual Git, TrueNAS, and optional APK release manager with safety gates. The GUI intentionally leaves the TrueNAS portion as copyable shell commands: the app must be stopped and started from the TrueNAS Apps UI around that block. @@ -71,6 +71,8 @@ OVERRIDABLE_TEST_LABELS = frozenset( ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") FAILURE_OUTPUT_LINE_LIMIT = 160 FAILURE_OUTPUT_CHARACTER_LIMIT = 20_000 +DEFAULT_GIT_TRUENAS_ONLY = True +DEFAULT_RUN_TESTS = True class ReleaseError(RuntimeError): @@ -104,15 +106,27 @@ class ReleaseVersion: version_code: int -def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]: - """Commands that must succeed before a release can be staged.""" - return ( +def full_release_checks( + npm: str, + *, + include_android: bool = True, + include_tests: bool = True, +) -> tuple[tuple[list[str], str], ...]: + """Commands that must succeed before an update can be staged.""" + test_checks = ( ([npm, "run", "content:test"], "Content tests"), ([npm, "run", "server:test"], "Server tests"), ([npm, "test"], "Application tests"), ([npm, "run", "test:shelved"], "Shelved-mode application tests"), + ) + required_checks = ( ([npm, "run", "content:publish"], "Publish verified 54-dungeon content manifest"), ([npm, "run", "build"], "Production build"), + ) + checks = (test_checks if include_tests else ()) + required_checks + if not include_android: + return checks + return checks + ( ([npm, "run", "android:sync"], "Android web build and Capacitor sync"), ) @@ -645,7 +659,7 @@ def launch_gui(*, smoke_test: bool = False) -> int: ) ttk.Label( title, - text="Validate → inspect → commit → push → publish signed APK", + text="Validate → inspect → commit → push → deploy, with optional signed APK", style="Sub.TLabel", ).grid(row=1, column=0, sticky="w", pady=(2, 0)) self.refresh_button = ttk.Button(title, text="Refresh", command=self.refresh) @@ -665,6 +679,12 @@ def launch_gui(*, smoke_test: bool = False) -> int: self.next_version_var = tk.StringVar() self.next_code_var = tk.StringVar() self.commit_message_var = tk.StringVar() + self.git_truenas_only_var = tk.BooleanVar( + value=DEFAULT_GIT_TRUENAS_ONLY + ) + self.run_tests_var = tk.BooleanVar(value=DEFAULT_RUN_TESTS) + self.release_mode_hint_var = tk.StringVar() + self.test_mode_hint_var = tk.StringVar() ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid( row=1, column=0, sticky="w" @@ -682,25 +702,31 @@ def launch_gui(*, smoke_test: bool = False) -> int: ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid( row=2, column=0, sticky="w", pady=(12, 0) ) - bump_box = ttk.Combobox( + self.bump_box = ttk.Combobox( release_panel, textvariable=self.bump_var, values=("Patch", "Minor", "Major"), state="readonly", width=10, ) - bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0)) - bump_box.bind("<>", lambda _event: self._suggest_versions()) + self.bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0)) + self.bump_box.bind("<>", lambda _event: self._suggest_versions()) ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid( row=3, column=0, sticky="w", pady=(9, 0) ) - ttk.Entry(release_panel, textvariable=self.next_version_var, width=15).grid( + self.next_version_entry = ttk.Entry( + release_panel, textvariable=self.next_version_var, width=15 + ) + self.next_version_entry.grid( row=3, column=1, sticky="ew", padx=(8, 20), pady=(9, 0) ) ttk.Label(release_panel, text="Next code", style="Muted.Panel.TLabel").grid( row=3, column=2, sticky="w", pady=(9, 0) ) - ttk.Entry(release_panel, textvariable=self.next_code_var, width=12).grid( + self.next_code_entry = ttk.Entry( + release_panel, textvariable=self.next_code_var, width=12 + ) + self.next_code_entry.grid( row=3, column=3, sticky="ew", padx=(8, 0), pady=(9, 0) ) ttk.Label(release_panel, text="Commit message", style="Muted.Panel.TLabel").grid( @@ -709,7 +735,7 @@ def launch_gui(*, smoke_test: bool = False) -> int: ttk.Entry(release_panel, textvariable=self.commit_message_var).grid( row=4, column=1, columnspan=3, sticky="ew", padx=(8, 0), pady=(9, 0) ) - ttk.Label(release_panel, text="Release notes", style="Muted.Panel.TLabel").grid( + ttk.Label(release_panel, text="Release notes (APK only)", style="Muted.Panel.TLabel").grid( row=5, column=0, sticky="nw", pady=(9, 0) ) self.release_notes = tk.Text( @@ -727,12 +753,42 @@ def launch_gui(*, smoke_test: bool = False) -> int: self.release_notes.grid( row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0) ) + self.release_mode_toggle = ttk.Checkbutton( + release_panel, + text="Git + TrueNAS only (skip Android version bump and APK publication)", + variable=self.git_truenas_only_var, + command=self._update_release_mode, + ) + self.release_mode_toggle.grid( + row=6, column=0, columnspan=4, sticky="w", pady=(12, 0) + ) + ttk.Label( + release_panel, + textvariable=self.release_mode_hint_var, + style="Muted.Panel.TLabel", + wraplength=610, + ).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0)) + self.run_tests_toggle = ttk.Checkbutton( + release_panel, + text="Run test suites (recommended)", + variable=self.run_tests_var, + command=self._update_test_mode, + ) + self.run_tests_toggle.grid( + row=8, column=0, columnspan=4, sticky="w", pady=(10, 0) + ) + ttk.Label( + release_panel, + textvariable=self.test_mode_hint_var, + style="Muted.Panel.TLabel", + wraplength=610, + ).grid(row=9, column=0, columnspan=4, sticky="w", pady=(4, 0)) secrets_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15) secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0)) secrets_panel.columnconfigure(1, weight=1) ttk.Label( - secrets_panel, text="Signing and Gitea", style="Panel.TLabel", + secrets_panel, text="Signing and Gitea (APK only)", style="Panel.TLabel", font=("Segoe UI Semibold", 12), ).grid(row=0, column=0, columnspan=3, sticky="w") ttk.Label( @@ -869,6 +925,54 @@ def launch_gui(*, smoke_test: bool = False) -> int: self.retry_button.grid(row=0, column=1, padx=7) self.cancel_button = ttk.Button(actions, text="Cancel", command=self.cancel, state="disabled") self.cancel_button.grid(row=0, column=2, padx=(7, 0)) + self._update_release_mode() + self._update_test_mode() + + def _update_release_mode(self) -> None: + git_truenas_only = self.git_truenas_only_var.get() + self.bump_box.configure(state="disabled" if git_truenas_only else "readonly") + entry_state = "disabled" if git_truenas_only else "normal" + self.next_version_entry.configure(state=entry_state) + self.next_code_entry.configure(state=entry_state) + if git_truenas_only: + self.release_button.configure(text="Validate and Push Git + TrueNAS Update") + self.release_mode_hint_var.set( + "Default mode: keeps the current Android version, skips Android sync and APK " + "publication, and prepares the pinned TrueNAS deployment commands after push." + ) + else: + self.release_button.configure(text="Validate, Push, and Publish APK") + self.release_mode_hint_var.set( + "APK mode: bumps the app and Android versions, runs Android sync, and publishes " + "the signed APK after the Git push." + ) + self._suggest_versions() + current_notes = self.release_notes.get("1.0", "end").strip() + server_default = "Healer Man Git and TrueNAS server update." + apk_default = ( + f"Healer Man {self.next_version_var.get()} app and Android update." + ) + generated_apk_notes = re.fullmatch( + r"Healer Man \d+\.\d+\.\d+ app and Android update\.", + current_notes, + ) + if git_truenas_only and (not current_notes or generated_apk_notes): + self._set_text(self.release_notes, server_default) + elif not git_truenas_only and ( + not current_notes or current_notes == server_default + ): + self._set_text(self.release_notes, apk_default) + + def _update_test_mode(self) -> None: + if self.run_tests_var.get(): + self.test_mode_hint_var.set( + "Runs content, server, normal-application, and shelved-application tests." + ) + else: + self.test_mode_hint_var.set( + "Skips those four test suites. Candidate safety checks, content publication, " + "the production build, and staged-file review still run." + ) def _command_text_widget(self, notebook: ttk.Notebook) -> tk.Text: frame = ttk.Frame(notebook) @@ -938,7 +1042,7 @@ def launch_gui(*, smoke_test: bool = False) -> int: self.last_commit = current_commit() self.current_version_var.set(package_version) self.current_code_var.set(str(release_version.version_code)) - self._suggest_versions() + self._update_release_mode() change_count = len( [line for line in status.splitlines() if not line.startswith("##")] ) @@ -956,10 +1060,6 @@ def launch_gui(*, smoke_test: bool = False) -> int: generate_truenas_verify_commands(), readonly=True, ) - if not self.release_notes.get("1.0", "end").strip(): - self.release_notes.insert( - "1.0", f"Healer Man {self.next_version_var.get()} app and Android update." - ) self.status_var.set("Ready") except Exception as exc: self.status_var.set("Configuration error") @@ -973,7 +1073,10 @@ def launch_gui(*, smoke_test: bool = False) -> int: next_code = suggest_version_code(next_version, release.version_code) self.next_version_var.set(next_version) self.next_code_var.set(str(next_code)) - self.commit_message_var.set(f"Release Healer Man {next_version}") + if self.git_truenas_only_var.get(): + self.commit_message_var.set("Update Healer Man server") + else: + self.commit_message_var.set(f"Release Healer Man {next_version}") except Exception as exc: self.status_var.set(str(exc)) @@ -1037,32 +1140,84 @@ def launch_gui(*, smoke_test: bool = False) -> int: def start_full_release(self) -> None: if self.busy: return + publish_apk = not self.git_truenas_only_var.get() + run_tests = self.run_tests_var.get() + test_summary = ( + "The four test suites will run." + if run_tests + else "The four test suites will be skipped; safety checks and the production build still run." + ) try: - version = self._requested_version() - credentials = self._credentials() - toolchain_ready, toolchain_detail = android_toolchain_status() - if not toolchain_ready: - raise ReleaseError(toolchain_detail) commit_message = self.commit_message_var.get().strip() - notes = self.release_notes.get("1.0", "end").strip() if not commit_message: raise ReleaseError("Commit message is required.") - if not notes: - raise ReleaseError("Release notes are required.") + version: ReleaseVersion | None = None + credentials: dict[str, str] | None = None + notes = "" + if publish_apk: + version = self._requested_version() + credentials = self._credentials() + toolchain_ready, toolchain_detail = android_toolchain_status() + if not toolchain_ready: + raise ReleaseError(toolchain_detail) + notes = self.release_notes.get("1.0", "end").strip() + if not notes: + raise ReleaseError("Release notes are required.") except Exception as exc: messagebox.showerror("Cannot start release", str(exc), parent=self.root) return + if not publish_apk: + if not messagebox.askyesno( + "Start Git + TrueNAS update?", + "Run the server validation and production build, review and commit the exact " + "changed files, then push main?\n\nThe Android version will not change and no " + "APK will be built or published. The Deploy tab will be pinned to the new commit.\n\n" + + test_summary, + parent=self.root, + ): + return + self._begin_worker( + "Validating Git + TrueNAS update", + lambda: self._full_release_worker( + None, + commit_message, + "", + None, + publish_apk=False, + run_tests=run_tests, + ), + ( + "Git main is published without changing the Android version or publishing " + "an APK.\n\nUse the TrueNAS Deploy tab after stopping the app, then the " + "Verify tab after restarting it." + ), + ) + return + assert version is not None + assert credentials is not None if not messagebox.askyesno( "Start full release?", f"Prepare Healer Man {version.version_name} (Android code {version.version_code})?\n\n" "The app will bump versions, run all release checks, then show the exact staged files " - "before it commits, pushes main, and publishes the signed APK.", + "before it commits, pushes main, and publishes the signed APK.\n\n" + + test_summary, parent=self.root, ): return self._begin_worker( "Validating release", - lambda: self._full_release_worker(version, commit_message, notes, credentials), + lambda: self._full_release_worker( + version, + commit_message, + notes, + credentials, + publish_apk=True, + run_tests=run_tests, + ), + ( + "Git main and the signed APK release are published.\n\nUse the TrueNAS Deploy " + "tab after stopping the app, then the Verify tab after restarting it." + ), ) def start_apk_only(self) -> None: @@ -1095,9 +1250,18 @@ def launch_gui(*, smoke_test: bool = False) -> int: self._begin_worker( "Publishing current APK", lambda: self._apk_only_worker(release_version, notes, credentials), + ( + "The signed APK release is published for the current Git commit.\n\nUse the " + "TrueNAS Deploy tab only if that commit is not already deployed." + ), ) - def _begin_worker(self, label: str, target: Callable[[], None]) -> None: + def _begin_worker( + self, + label: str, + target: Callable[[], None], + success_message: str, + ) -> None: self.busy = True self.cancel_event.clear() self.status_var.set(label) @@ -1105,11 +1269,21 @@ def launch_gui(*, smoke_test: bool = False) -> int: self.release_button.configure(state="disabled") self.retry_button.configure(state="disabled") self.refresh_button.configure(state="disabled") + self.release_mode_toggle.configure(state="disabled") + self.run_tests_toggle.configure(state="disabled") self.cancel_button.configure(state="normal") self._append_log(f"\n=== {label} ===") - threading.Thread(target=self._worker_wrapper, args=(target,), daemon=True).start() + threading.Thread( + target=self._worker_wrapper, + args=(target, success_message), + daemon=True, + ).start() - def _worker_wrapper(self, target: Callable[[], None]) -> None: + def _worker_wrapper( + self, + target: Callable[[], None], + success_message: str, + ) -> None: try: target() except ReleaseCancelled as exc: @@ -1117,7 +1291,7 @@ def launch_gui(*, smoke_test: bool = False) -> int: except Exception as exc: self.events.put(("done", "error", str(exc))) else: - self.events.put(("done", "success", "Release workflow completed.")) + self.events.put(("done", "success", success_message)) def _preflight_git(self, *, require_clean: bool) -> None: if git_output("branch", "--show-current") != "main": @@ -1137,10 +1311,13 @@ def launch_gui(*, smoke_test: bool = False) -> int: def _full_release_worker( self, - version: ReleaseVersion, + version: ReleaseVersion | None, commit_message: str, notes: str, - credentials: dict[str, str], + credentials: dict[str, str] | None, + *, + publish_apk: bool, + run_tests: bool, ) -> None: self._preflight_git(require_clean=False) self._log_event( @@ -1153,11 +1330,21 @@ def launch_gui(*, smoke_test: bool = False) -> int: f"({len(candidate_paths)} changed path(s), including untracked files)" ) npm = shutil.which("npm") - powershell = shutil.which("powershell.exe") or shutil.which("powershell") - if not npm or not powershell: - raise ReleaseError("npm and Windows PowerShell must be available on PATH.") + if not npm: + raise ReleaseError("npm must be available on PATH.") + powershell = None + if publish_apk: + if version is None or credentials is None: + raise ReleaseError("APK publication requires version and signing details.") + powershell = shutil.which("powershell.exe") or shutil.which("powershell") + if not powershell: + raise ReleaseError("Windows PowerShell must be available on PATH.") - managed_files = (PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE) + managed_files = ( + (PACKAGE_FILE, PACKAGE_LOCK_FILE, RELEASE_VERSION_FILE) + if publish_apk + else () + ) backups = { path: path.read_bytes() if path.exists() else None for path in managed_files } @@ -1165,16 +1352,32 @@ def launch_gui(*, smoke_test: bool = False) -> int: committed = False base_commit = current_commit() try: - self._run_command( - [npm, "version", version.version_name, "--no-git-tag-version"], - "Bump package version", - ) - write_release_version(version) - self._log_event( - f"Recorded Android version {version.version_name} / {version.version_code}." - ) + if publish_apk: + assert version is not None + self._run_command( + [npm, "version", version.version_name, "--no-git-tag-version"], + "Bump package version", + ) + write_release_version(version) + self._log_event( + f"Recorded Android version {version.version_name} / {version.version_code}." + ) + else: + self._log_event( + "Git + TrueNAS mode: keeping the current Android version and " + "skipping APK publication." + ) + if not run_tests: + self._log_event( + "⚠ Test suites disabled: skipping content, server, normal-application, " + "and shelved-application tests." + ) - for command, label in full_release_checks(npm): + for command, label in full_release_checks( + npm, + include_android=publish_apk, + include_tests=run_tests, + ): try: self._run_command(command, label) except ReleaseCommandError as failure: @@ -1221,15 +1424,49 @@ def launch_gui(*, smoke_test: bool = False) -> int: if not staged_paths: raise ReleaseError("There are no staged changes to release.") stat = git_output("diff", "--cached", "--stat") + if publish_apk: + assert version is not None + review_heading = ( + f"Version: {version.version_name}\n" + f"Android code: {version.version_code}\n" + ) + review_question = "Commit, push main, and publish the APK?" + approval_title = "Review staged release" + approval_heading = "Inspect every staged path before publishing" + approve_label = "Commit, Push, and Publish" + cancel_label = "Cancel release" + else: + current_android = read_release_version() + review_heading = ( + "Mode: Git + TrueNAS only\n" + f"Android version remains: {current_android.version_name} " + f"(code {current_android.version_code})\n" + ) + review_question = ( + "Commit and push main without publishing an APK? The TrueNAS Deploy tab " + "will be pinned to the new commit." + ) + approval_title = "Review staged Git + TrueNAS update" + approval_heading = "Inspect every staged path before pushing" + approve_label = "Commit and Push" + cancel_label = "Cancel update" + review_heading += ( + f"Test suites: {'run' if run_tests else 'SKIPPED'}\n" + ) review = ( - f"Version: {version.version_name}\n" - f"Android code: {version.version_code}\n" - f"Commit: {commit_message}\n\n" + review_heading + + f"Commit: {commit_message}\n\n" f"Staged paths ({len(staged_paths)}):\n" + "\n".join(staged_paths) - + f"\n\nDiff summary:\n{stat}\n\nCommit, push main, and publish the APK?" + + f"\n\nDiff summary:\n{stat}\n\n{review_question}" + ) + approved = self._request_approval( + review, + title=approval_title, + heading=approval_heading, + approve_label=approve_label, + cancel_label=cancel_label, ) - approved = self._request_approval(review) if not approved: raise ReleaseCancelled("Release stopped at the staged-file review.") @@ -1244,7 +1481,11 @@ def launch_gui(*, smoke_test: bool = False) -> int: f"Remote main is {remote_commit or 'unknown'}, expected {release_commit}." ) self.events.put(("commit", release_commit)) - self._publish_apk(powershell, version, notes, credentials) + if publish_apk: + assert powershell is not None + assert version is not None + assert credentials is not None + self._publish_apk(powershell, version, notes, credentials) except Exception: if current_commit() != base_commit: committed = True @@ -1257,18 +1498,31 @@ def launch_gui(*, smoke_test: bool = False) -> int: stderr=subprocess.DEVNULL, check=False, ) - for path, content in backups.items(): - if content is None: - if path.exists(): - path.unlink() - else: - path.write_bytes(content) - self._log_event("Restored version files because no release commit was created.") + if publish_apk: + for path, content in backups.items(): + if content is None: + if path.exists(): + path.unlink() + else: + path.write_bytes(content) + self._log_event( + "Restored version files because no release commit was created." + ) + else: + self._log_event( + "No update commit was created; reviewed files were left in the working tree." + ) else: - self._log_event( - "The release commit exists. Version files were kept; use " - "'Publish Current APK Only' after correcting the publication problem." - ) + if publish_apk: + self._log_event( + "The release commit exists. Version files were kept; use " + "'Publish Current APK Only' after correcting the publication problem." + ) + else: + self._log_event( + "The update commit exists locally; resolve the push problem without " + "rewriting the commit." + ) raise def _apk_only_worker( @@ -1519,27 +1773,28 @@ def launch_gui(*, smoke_test: bool = False) -> int: self.release_button.configure(state="normal") self.retry_button.configure(state="normal") self.refresh_button.configure(state="normal") + self.release_mode_toggle.configure(state="normal") + self.run_tests_toggle.configure(state="normal") self.cancel_button.configure(state="disabled") self.keystore_password_var.set("") self.key_password_var.set("") if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file(): self.token_var.set(read_saved_gitea_token()) if result == "success": - self.status_var.set("Release completed") - self._append_log("\n✓ Release workflow completed successfully.") + self.status_var.set("Workflow completed") + self._append_log("\n✓ Workflow completed successfully.") messagebox.showinfo( - "Release complete", - "Git main and the signed APK release are published.\n\n" - "Use the TrueNAS Deploy tab after stopping the app, then the Verify tab after restarting it.", + "Workflow complete", + detail, parent=self.root, ) elif result == "cancelled": - self.status_var.set("Release cancelled") + self.status_var.set("Workflow cancelled") self._append_log(f"\nCancelled: {detail}") else: - self.status_var.set("Release failed") + self.status_var.set("Workflow failed") self._append_log(f"\nERROR: {detail}") - messagebox.showerror("Release failed", detail, parent=self.root) + messagebox.showerror("Workflow failed", detail, parent=self.root) self.refresh() def _close(self) -> None: diff --git a/scripts/release_manager_test.py b/scripts/release_manager_test.py index 3d4ab11f..2eb4357b 100644 --- a/scripts/release_manager_test.py +++ b/scripts/release_manager_test.py @@ -22,6 +22,10 @@ class VersionTests(unittest.TestCase): class SafetyTests(unittest.TestCase): + def test_safe_update_options_are_enabled_by_default(self): + self.assertTrue(release_manager.DEFAULT_GIT_TRUENAS_ONLY) + self.assertTrue(release_manager.DEFAULT_RUN_TESTS) + def test_detects_outputs_and_secrets(self): paths = [ "src/game/example.ts", @@ -54,6 +58,28 @@ class SafetyTests(unittest.TestCase): self.assertLess(publish_index, commands.index(["npm", "run", "build"])) self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"])) + def test_git_and_truenas_checks_skip_only_the_android_stage(self): + checks = release_manager.full_release_checks("npm", include_android=False) + commands = [command for command, _label in checks] + self.assertIn(["npm", "run", "content:publish"], commands) + self.assertIn(["npm", "run", "build"], commands) + self.assertNotIn(["npm", "run", "android:sync"], commands) + + def test_disabled_test_suites_keep_safety_and_build_checks(self): + checks = release_manager.full_release_checks( + "npm", + include_android=False, + include_tests=False, + ) + commands = [command for command, _label in checks] + self.assertEqual( + commands, + [ + ["npm", "run", "content:publish"], + ["npm", "run", "build"], + ], + ) + def test_only_test_failures_may_be_overridden(self): checks = release_manager.full_release_checks("npm") overridable = { diff --git a/scripts/test_balance_lab.py b/scripts/test_balance_lab.py index 9763a353..74c38c6b 100644 --- a/scripts/test_balance_lab.py +++ b/scripts/test_balance_lab.py @@ -6,12 +6,18 @@ from scripts.balance_lab import ( automatic_parties, coverage_parties, random_party, + runtime_session_command, search_bosses, tuning_label, ) class BalanceLabControllerTests(unittest.TestCase): + def test_headless_runtime_uses_the_no_watch_vite_mode(self) -> None: + command = runtime_session_command("wow", 40) + self.assertEqual(command[1:3], ["--mode", "balance-lab"]) + self.assertEqual(command[-4:], ["--source", "wow", "--level", "40"]) + def test_random_party_is_repeatable_and_complete(self) -> None: builds = [ {"id": "tank-a", "role": "tank"}, diff --git a/server/balance-lab-jobs.mjs b/server/balance-lab-jobs.mjs index 5be72660..37ab3eb7 100644 --- a/server/balance-lab-jobs.mjs +++ b/server/balance-lab-jobs.mjs @@ -227,6 +227,15 @@ function lineCollector(onLine) { }; } +function processFailureMessage(job, code) { + if (job.logTail.some((line) => line.includes("ENOSPC: System limit for number of file watchers reached"))) { + return "Balance Lab could not start because the server file-watcher limit was exhausted."; + } + return code === 0 + ? "Balance Lab ended without producing its suite report." + : `Balance Lab process exited with code ${code ?? "unknown"}.`; +} + export function createBalanceLabJobService(options = {}) { const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url))); const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime"); @@ -411,9 +420,7 @@ export function createBalanceLabJobService(options = {}) { } else { job.status = "failed"; job.error = error?.message - ?? (code === 0 - ? "Balance Lab ended without producing its suite report." - : `Balance Lab process exited with code ${code ?? "unknown"}.`); + ?? processFailureMessage(job, code); } persistJob(job); pump(); diff --git a/server/balance-lab-jobs.test.mjs b/server/balance-lab-jobs.test.mjs index 8e39273d..1a90e804 100644 --- a/server/balance-lab-jobs.test.mjs +++ b/server/balance-lab-jobs.test.mjs @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import test from "node:test"; +import { resolveConfig } from "vite"; import { balanceLabCommand, createBalanceLabJobService, @@ -13,6 +14,12 @@ import { normalizeBalanceLabJobSpec, } from "./balance-lab-jobs.mjs"; +test("disables public assets and filesystem watching in Balance Lab Vite mode", async () => { + const config = await resolveConfig({ server: { watch: null } }, "serve", "balance-lab"); + assert.equal(config.publicDir, ""); + assert.deepEqual(config.server.watch?.ignored, ["**/*"]); +}); + test("normalizes and bounds server Balance Lab requests", () => { const spec = normalizeBalanceLabJobSpec({ source: "ALL", @@ -155,6 +162,42 @@ test("queues a job, reports progress, and exposes only allow-listed artifacts", } }); +test("reports exhausted file watchers as a specific startup failure", async () => { + const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-watchers-")); + try { + const spawnProcess = () => { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => child.emit("close", null); + setImmediate(() => { + child.stderr.write("Error: ENOSPC: System limit for number of file watchers reached, watch '/app/public/assets/example'\n"); + child.stdout.end(); + child.stderr.end(); + child.emit("close", 1); + }); + return child; + }; + const service = createBalanceLabJobService({ + dataDir: directory, + projectRoot: directory, + spawnProcess, + pythonExecutable: "python-test", + }); + const started = service.start({ + source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1, + durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"], + }, { username: "Phenom" }); + await new Promise((resolve) => setImmediate(resolve)); + const failed = service.get(started.id); + assert.equal(failed.status, "failed"); + assert.match(failed.error, /file-watcher limit was exhausted/i); + service.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("restores completed reports as previous runs after a server restart", async () => { const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-history-")); try { diff --git a/vite.config.ts b/vite.config.ts index f4c0e40e..2462da4f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ const packageVersion = (JSON.parse(readFileSync(new URL("./package.json", import export default defineConfig(({ mode }) => { const androidBuild = mode === "android"; const shelvedModesEnabled = mode === "shelved"; + const balanceLabRuntime = mode === "balance-lab"; const downloadableBuild = androidBuild || mode === "production" || shelvedModesEnabled; return { plugins: [react(), { @@ -40,7 +41,7 @@ export default defineConfig(({ mode }) => { }); }, }], - publicDir: androidBuild ? ".android-public" : downloadableBuild ? ".web-public" : "public", + publicDir: balanceLabRuntime ? false : androidBuild ? ".android-public" : downloadableBuild ? ".web-public" : "public", define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(packageVersion), "import.meta.env.VITE_CONTENT_MODE": JSON.stringify(downloadableBuild ? "downloadable" : "bundled"), @@ -84,10 +85,12 @@ export default defineConfig(({ mode }) => { // while iCloud or conversion tools are writing them. They are not app // source, so watching them only risks taking the dev server down with // EBUSY on Windows. - ignored: [ - "**/dungeon-pipeline/**", - "**/playtest-artifacts/**", - ], + ignored: balanceLabRuntime + ? ["**/*"] + : [ + "**/dungeon-pipeline/**", + "**/playtest-artifacts/**", + ], }, }, build: {