From 5fe28cc2d96b21d52fc8220802706ec0853939aa Mon Sep 17 00:00:00 2001 From: Trae User Date: Sat, 19 Sep 2026 12:38:43 +0800 Subject: [PATCH 1/7] feat: add development environment launchers --- .../skills/development-environment/SKILL.md | 57 ++++ .../.openspec.yaml | 2 + .../design.md | 82 +++++ .../proposal.md | 32 ++ .../development-environment-launcher/spec.md | 77 +++++ .../tasks.md | 18 ++ scripts/dev_desktop.bat | 19 +- scripts/dev_desktop.sh | 14 +- scripts/dev_environment.py | 303 ++++++++++++++++++ scripts/dev_tui.bat | 24 ++ scripts/dev_tui.sh | 18 ++ scripts/dev_web.bat | 9 +- scripts/dev_web.sh | 18 ++ scripts/dev_windows_env.bat | 37 +++ tests/scripts/dev_environment_test.py | 127 ++++++++ 15 files changed, 815 insertions(+), 22 deletions(-) create mode 100644 .agents/skills/development-environment/SKILL.md create mode 100644 openspec/changes/add-development-environment-launcher/.openspec.yaml create mode 100644 openspec/changes/add-development-environment-launcher/design.md create mode 100644 openspec/changes/add-development-environment-launcher/proposal.md create mode 100644 openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md create mode 100644 openspec/changes/add-development-environment-launcher/tasks.md create mode 100644 scripts/dev_environment.py create mode 100644 scripts/dev_tui.bat create mode 100644 scripts/dev_tui.sh create mode 100644 scripts/dev_web.sh create mode 100644 scripts/dev_windows_env.bat create mode 100644 tests/scripts/dev_environment_test.py diff --git a/.agents/skills/development-environment/SKILL.md b/.agents/skills/development-environment/SKILL.md new file mode 100644 index 00000000..d1cf3713 --- /dev/null +++ b/.agents/skills/development-environment/SKILL.md @@ -0,0 +1,57 @@ +--- +name: development-environment +description: Start ACECode's Web, Desktop, or TUI development environment safely, reusing a compatible build from a registered Git worktree when possible. +--- + +# Development Environment + +Use this skill when the user asks to run, start, or open the ACECode development environment. + +## Select the target + +If the request does not name a target, ask exactly which development surface to run: + +- **Web** — starts the daemon-backed browser UI. +- **Desktop** — starts the native desktop shell against the local development frontend. +- **TUI** — starts the terminal interface in a new terminal window. + +Do not start anything until the user selects one target. If they name a target, proceed without repeating the question. + +## Use the shared launcher + +Run the target-specific repository launcher instead of implementing launch logic in the conversation: + +```powershell +.\scripts\dev_web.bat +.\scripts\dev_desktop.bat +.\scripts\dev_tui.bat +``` + +On macOS or Linux: + +```bash +./scripts/dev_web.sh +./scripts/dev_desktop.sh +./scripts/dev_tui.sh +``` + +Pass `--build-dir ` only when the user explicitly supplies a candidate build directory. Do not copy `acecode`, `acecode-desktop`, DLLs, or other build artifacts between worktrees. + +## Build reuse and rebuild policy + +The launcher discovers worktrees with `git worktree list --porcelain`, reads candidate `CMakeCache.txt` files, and validates the configured source path, current commit, platform, architecture clues, required executable, and Desktop configuration before reuse. + +Every launch incrementally builds the verified target, so source changes are incorporated even when the configured build is reused. Web and Desktop also refresh frontend assets when their inputs are newer than `web/dist`. + +If no compatible configured build exists, the launcher reports the platform CMake preset and asks for confirmation before configuration. Preserve that safety boundary: + +- State that the launcher needs to configure and build the selected target and name the preset. +- Ask the user for explicit confirmation before adding `--yes`. +- After confirmation, run the same target-specific launcher with `--yes`. +- If the user declines, do not configure, compile, or start a surface. + +The shared launcher calls the existing Python surface launchers: `scripts/dev_web.py` for Web and `scripts/dev_desktop.py` for Desktop. Web uses a worktree-isolated runtime directory and opens its resulting local URL; Desktop opens its application window; TUI opens a new terminal window. + +## Report outcome + +After a successful command, report the selected target and whether the build was reused or compiled. For Web, include the URL printed by the launcher. If startup fails, provide the launcher error and do not claim the environment is running. diff --git a/openspec/changes/add-development-environment-launcher/.openspec.yaml b/openspec/changes/add-development-environment-launcher/.openspec.yaml new file mode 100644 index 00000000..eaa6b1cd --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-19 diff --git a/openspec/changes/add-development-environment-launcher/design.md b/openspec/changes/add-development-environment-launcher/design.md new file mode 100644 index 00000000..945dfe46 --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/design.md @@ -0,0 +1,82 @@ +# Design + +## Context + +See `proposal.md` for motivation and `specs/development-environment-launcher/spec.md` for behavioral requirements. The repository already has Python-backed Web and Desktop launchers with thin platform wrappers. Web currently accepts an explicit build directory and runtime directory, while Desktop accepts an explicit build directory and rebuilds `web/dist` when its inputs are newer. TUI has no comparable wrapper. + +## Goals / Non-Goals + +**Goals:** + +- Provide one shared Python orchestration entry point and thin Windows/POSIX wrappers. +- Reuse existing Web and Desktop launch scripts rather than duplicating their surface-specific behavior. +- Reliably identify compatible builds from Git-registered worktrees before proposing a local build. +- Keep development daemons isolated per worktree and make start outcomes visible. + +**Non-Goals:** + +- Change production daemon, Desktop, TUI, or CMake behavior. +- Copy build artifacts, DLLs, or resources between worktrees. +- Search arbitrary user directories outside worktrees registered by the current Git repository. +- Automatically compile without interactive confirmation. + +## Decisions + +### Use a Python orchestration layer with thin platform wrappers + +A new `scripts/dev_environment.py` will parse the selected target and coordinate discovery, confirmation, incremental building, and launch. Dedicated target wrappers will select Python and a fixed target: `dev_web.bat` / `dev_web.sh`, `dev_desktop.bat` / `dev_desktop.sh`, and `dev_tui.bat` / `dev_tui.sh`. Python matches the existing launcher implementation and is portable across Windows, macOS, and Linux. + +Alternatives considered: + +- Separate shell implementations would duplicate platform logic and Git/CMake parsing. +- Extending `dev_web.py` or `dev_desktop.py` would couple target-independent discovery to a single surface. + +### Discover builds only from Git worktree registrations + +The orchestration layer will use `git worktree list --porcelain` from the current repository. It will inspect each registered worktree's candidate build directories and read `CMakeCache.txt` to confirm the configured source directory. It will compare the candidate source worktree's `HEAD` to the current worktree's `HEAD` and validate platform, generator architecture clues, selected target configuration, and executable presence. + +Alternatives considered: + +- Scan sibling or home directories: this can find unrelated repositories and is slower. +- Compare only executable timestamps: timestamps do not prove the source revision or Desktop capability. + +### Delegate surface-specific startup + +The shared launcher performs the target's incremental CMake build after it validates or configures a build directory. For Web and Desktop, it then invokes the existing Python surface launchers directly, forwarding the validated `--build-dir`; this avoids recursing through target wrappers. The Desktop surface launcher continues to refresh frontend assets, and the shared launcher adds the equivalent freshness check before Web launch. Web receives a deterministic runtime directory below the current worktree's ignored development state. TUI is started from the validated `acecode` binary in a new terminal window using platform-specific process invocation. + +Alternatives considered: + +- Reimplement Web and Desktop startup in the new tool: would create two sources of truth for Web assets and Desktop development-mode behavior. +- Run TUI in the current terminal: conflicts with the agreed ability to continue launcher work after opening TUI. + +### Initialize the Windows C++ toolchain in batch entry points + +The three Windows target wrappers will call a shared batch helper before invoking Python. The helper locates `vswhere.exe` from the Visual Studio installer location, asks it for an installation containing the x64 C++ tools component, and calls that installation's `VsDevCmd.bat` with `-arch=amd64 -host_arch=amd64`. It preserves the caller's command context while supplying the standard-library and linker paths that CMake needs. If discovery fails, it reports the Build Tools C++ workload requirement and exits before Python runs. + +Alternatives considered: + +- Require callers to use a Developer Command Prompt: contradicts direct double-click and normal-shell entry point behavior. +- Duplicate Visual Studio path discovery in all three wrappers: risks inconsistent architecture and error handling. + +### Require interactive confirmation only for a new configuration + +When discovery cannot produce a compatible result, the tool calculates the native CMake preset for the selected target and prints configure/build commands. It asks a yes/no question only when stdin is interactive; non-interactive invocations fail with the same instructions rather than implicitly configuring. Once a build directory has been validated or configured, the target's incremental build runs without another confirmation so each launch reflects current sources. + +Alternatives considered: + +- Auto-build: potentially expensive and unexpected. +- Always fail: forces developers to reconstruct platform-specific presets manually. + +## Risks / Trade-offs + +- [A valid external build is configured with an unusual directory layout] → inspect standard build directories plus explicit `--build-dir`; require an exact `CMakeCache.txt` source match. +- [CMake does not expose a fully portable architecture field] → require a runnable platform-native executable and compare configured generator/platform fields when present; reject uncertain configurations. +- [A worktree-specific runtime path is untracked] → create it under the repository's ignored `.acecode` development state and document it in the launcher output. +- [A platform lacks a supported graphical terminal launcher] → report the exact TUI executable command instead of silently starting TUI in the caller's terminal. + +## Migration Plan + +1. Add the shared launcher and thin wrappers without changing existing Web or Desktop launch commands. +2. Add the repository-local skill, directing requests through the shared policy. +3. Validate no-target selection, verified reuse, rejected reuse, declined rebuild, and each platform wrapper through focused tests or script help checks. +4. Roll back by removing the new launcher, wrappers, and skill; existing Web and Desktop launchers remain unchanged. diff --git a/openspec/changes/add-development-environment-launcher/proposal.md b/openspec/changes/add-development-environment-launcher/proposal.md new file mode 100644 index 00000000..0220c4cf --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/proposal.md @@ -0,0 +1,32 @@ +# Proposal + +## Why + +Developers currently need to choose and invoke separate Web or Desktop launchers manually, while TUI startup and reuse of compatible build outputs require repository-specific knowledge. A single cross-platform entry point should guide the target selection and safely reuse an existing build from a related worktree when possible. + +## What Changes + +- Add a cross-platform development-environment launcher that starts the Web daemon, Desktop shell, or terminal UI using existing repository launchers and build outputs. +- Add dedicated Windows and POSIX entry points for Web, Desktop, and TUI so developers can start the desired target without providing a target argument or using an agent skill. +- Discover related worktrees through Git and validate a reusable build against the current source revision, platform, architecture, and requested target before using it. +- Incrementally rebuild every verified build before launch so source changes are incorporated; require confirmation only before configuring a missing or incompatible build. +- Initialize the Windows Visual Studio C++ developer environment from the direct launchers so incremental builds work from a normal shell or double-clicked batch file. +- Give each Web development workspace an isolated daemon runtime directory and open the selected development surface after a successful start. +- Add a repository-local skill that asks for a target when omitted and applies the same validation and launch policy. + +## Capabilities + +### New Capabilities + +- `development-environment-launcher`: Guided, cross-platform selection, validation, and startup of ACECode Web, Desktop, and TUI development environments. + +### Modified Capabilities + +- None. + +## Impact + +- New shared launcher logic and thin `.bat` and `.sh` entry points under `scripts/`. +- Existing `scripts/dev_web.*` and `scripts/dev_desktop.*` remain the surface-specific launch mechanisms invoked by the new launcher. +- New repository-local skill under `.agents/skills/`. +- CMake preset selection and Git worktree metadata are used for build discovery and validation; no application protocol or production runtime behavior changes. diff --git a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md new file mode 100644 index 00000000..3da4968b --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md @@ -0,0 +1,77 @@ +# Spec Delta + +## Purpose + +Provide a safe, consistent way to start ACECode development surfaces across supported platforms without manually locating compatible build artifacts. + +## ADDED Requirements + +### Requirement: Development target selection +The development-environment launcher SHALL start exactly one selected development target: Web, Desktop, or TUI. When no target is provided to an interactive launcher invocation, it SHALL prompt the developer to select one. A repository-local assistant skill SHALL ask the developer to select one of those targets when a request to run the development environment does not name a target. + +#### Scenario: Interactive target selection +- **WHEN** a developer starts the launcher without a target in an interactive terminal +- **THEN** the launcher prompts for Web, Desktop, or TUI and starts only the selected target + +#### Scenario: Skill target selection +- **WHEN** a developer asks the repository-local skill to run the development environment without naming a target +- **THEN** the skill asks whether to run Web, Desktop, or TUI before starting work + +### Requirement: Compatible build reuse +Before requesting a rebuild, the launcher and skill SHALL search the Git worktrees registered for the current repository for a compatible existing build. A build is reusable only when its configured source worktree is at the current worktree's commit, its executable is runnable on the current platform and architecture, and it contains the executable required by the selected target. A Desktop target additionally requires a Desktop-enabled build and Desktop executable. The launcher and skill SHALL use a verified build directory directly and SHALL NOT copy build executables or their dependencies into the current worktree. + +#### Scenario: Reuse a matching Web build +- **WHEN** a registered worktree has a matching commit and a compatible `acecode` executable +- **THEN** the launcher starts the Web target with that build directory + +#### Scenario: Reject an incompatible Desktop build +- **WHEN** a matching build lacks Desktop support or the Desktop executable +- **THEN** the launcher does not use it for the Desktop target + +### Requirement: Fresh incremental builds and configuration confirmation +Before starting a selected target from a compatible build directory, the launcher SHALL run that target's incremental CMake build so changed source files are incorporated. Before starting Web or Desktop, it SHALL also ensure the development frontend assets are current. When no compatible configured build exists, the launcher and skill SHALL report the missing requirement and the CMake preset selected for the current platform, then obtain explicit developer confirmation before configuring a new build. A declined confirmation SHALL leave source and build files unchanged and SHALL not start a development target. + +#### Scenario: Refresh a compatible build +- **WHEN** a compatible build exists and source files have changed +- **THEN** the launcher runs the selected target's incremental build before starting it + +#### Scenario: Confirm a required configuration +- **WHEN** the selected target has no compatible build and the developer confirms the proposed configuration +- **THEN** the launcher configures and incrementally builds the required target before starting it + +#### Scenario: Decline a required configuration +- **WHEN** the selected target has no compatible build and the developer declines the proposed configuration +- **THEN** the launcher exits without configuring, compiling, or starting a target + +### Requirement: Windows compiler environment initialization +Before a Windows target-specific entry point runs an incremental CMake build, it SHALL initialize an installed Visual Studio C++ developer environment for the host and target architecture. If no suitable Visual Studio C++ tools installation is available, it SHALL exit before invoking the shared launcher and report how to install the required Build Tools workload. + +#### Scenario: Start from a normal Windows shell +- **WHEN** a developer starts a Windows target-specific entry point from a shell without Visual Studio compiler variables +- **THEN** the entry point initializes the developer environment and the incremental build receives the C++ standard-library include paths + +#### Scenario: Missing Visual Studio C++ tools +- **WHEN** a Windows target-specific entry point cannot locate a Visual Studio C++ developer command script +- **THEN** it reports the missing Build Tools prerequisite and does not start a build + +### Requirement: Target-specific startup +The launcher SHALL reuse the repository's existing Web and Desktop launch scripts for those targets. It SHALL start TUI in a new terminal window. Web startup SHALL use a runtime directory isolated to the current worktree and SHALL open the resulting local Web URL after successful startup. Desktop startup SHALL open the Desktop application after successful startup. + +#### Scenario: Isolated Web startup +- **WHEN** a developer starts the Web target for a worktree +- **THEN** its daemon uses a worktree-specific runtime directory and opens that daemon's URL + +#### Scenario: TUI startup +- **WHEN** a developer starts the TUI target +- **THEN** the launcher opens the TUI executable in a new terminal window + +### Requirement: Target-specific cross-platform entry points +The repository SHALL provide dedicated thin Windows and POSIX entry points for Web, Desktop, and TUI. Each entry point SHALL select its target without requiring a target argument, delegate to the shared development-environment launcher, pass supported command-line arguments through unchanged, and provide a clear error when no supported Python interpreter is available. + +#### Scenario: Windows Web launch +- **WHEN** a Windows developer runs the Web batch entry point +- **THEN** it delegates to the shared launcher with the Web target selected + +#### Scenario: POSIX TUI launch +- **WHEN** a macOS or Linux developer runs the TUI shell entry point +- **THEN** it delegates to the shared launcher with the TUI target selected diff --git a/openspec/changes/add-development-environment-launcher/tasks.md b/openspec/changes/add-development-environment-launcher/tasks.md new file mode 100644 index 00000000..b147425e --- /dev/null +++ b/openspec/changes/add-development-environment-launcher/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +## 1. Shared launcher + +- [x] 1.1 Add the cross-platform Python launcher with interactive target selection, target parsing, and platform-specific CMake preset selection; verify its help output and target-validation behavior. +- [x] 1.2 Implement Git worktree build discovery and compatibility validation for source revision, executable, platform/architecture, and Desktop configuration; verify focused tests cover accepted and rejected candidates. +- [x] 1.3 Implement automatic incremental builds for verified configured builds, confirmation only for missing/incompatible configuration, and explicit non-interactive or declined-configuration failures; verify no configure command runs without confirmation. + +## 2. Target startup and entry points + +- [x] 2.1 Delegate Web and Desktop starts to the existing Python surface launchers after refreshing frontend assets, use an isolated Web runtime directory, and report launch outcomes; verify forwarded arguments with stub launchers. +- [x] 2.2 Start TUI from the verified executable in a new terminal window on supported platforms and report unsupported terminal-launch behavior; verify command construction with focused tests. +- [x] 2.3 Provide target-specific `dev_web`, `dev_desktop`, and `dev_tui` Windows/POSIX entry points that select a fixed target and forward supported arguments unchanged; verify syntax and `--help` delegation on available platforms. + +## 3. Windows toolchain and verification + +- [x] 3.1 Add a shared Windows Visual Studio developer-environment helper and invoke it from each target-specific batch entry point; verify a normal shell receives C++ compiler include paths. +- [x] 3.2 Extend focused launcher tests for toolchain initialization, incremental builds, frontend refresh, and target-specific wrappers; run them with Python compilation checks and `openspec validate add-development-environment-launcher --strict`. diff --git a/scripts/dev_desktop.bat b/scripts/dev_desktop.bat index 750125fa..83b9e080 100644 --- a/scripts/dev_desktop.bat +++ b/scripts/dev_desktop.bat @@ -1,25 +1,24 @@ @echo off -REM ACECode Desktop 一键开发脚本 (Windows) -REM 用法: scripts\dev_desktop.bat [选项] -REM 详见 python scripts\dev_desktop.py --help - -setlocal enabledelayedexpansion +REM ACECode Desktop development launcher (Windows) +REM Usage: scripts\dev_desktop.bat [Desktop launcher options] +setlocal set "SCRIPT_DIR=%~dp0" +call "%SCRIPT_DIR%dev_windows_env.bat" +if errorlevel 1 exit /b %errorlevel% -REM 选择 python 解释器 where python >nul 2>&1 -if %errorlevel%==0 ( +if not errorlevel 1 ( set "PYTHON=python" ) else ( where py >nul 2>&1 - if %errorlevel%==0 ( + if not errorlevel 1 ( set "PYTHON=py" ) else ( - echo [ERROR] 未找到 python 或 py,请先安装 Python 3.8+ + echo [ERROR] python or py was not found. Install Python 3.8+ first. exit /b 1 ) ) -"%PYTHON%" "%SCRIPT_DIR%dev_desktop.py" %* +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" desktop %* exit /b %errorlevel% diff --git a/scripts/dev_desktop.sh b/scripts/dev_desktop.sh index 09d7b360..618d38a1 100755 --- a/scripts/dev_desktop.sh +++ b/scripts/dev_desktop.sh @@ -1,20 +1,18 @@ #!/bin/bash -# ACECode Desktop 一键开发脚本 (macOS / Linux) -# 用法: ./scripts/dev_desktop.sh [选项] -# 详见 python scripts/dev_desktop.py --help +# ACECode Desktop development launcher (macOS / Linux) +# Usage: ./scripts/dev_desktop.sh [Desktop launcher options] set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# 选择 python 解释器 -if command -v python3 &>/dev/null; then +if command -v python3 >/dev/null 2>&1; then PYTHON=python3 -elif command -v python &>/dev/null; then +elif command -v python >/dev/null 2>&1; then PYTHON=python else - echo "[ERROR] 未找到 python3 或 python,请先安装 Python 3.8+" + echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2 exit 1 fi -exec "$PYTHON" "$SCRIPT_DIR/dev_desktop.py" "$@" +exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" desktop "$@" diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py new file mode 100644 index 00000000..aab8ccf1 --- /dev/null +++ b/scripts/dev_environment.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Launch ACECode Web, Desktop, or TUI development environments safely.""" + +from __future__ import annotations + +import argparse +import os +import platform +import importlib.util +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +TARGETS = ("web", "desktop", "tui") + + +@dataclass(frozen=True) +class BuildCandidate: + build_dir: Path + source_dir: Path + executable: Path + + +def project_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def native_executable_name(name: str) -> str: + return f"{name}.exe" if os.name == "nt" else name + + +def executable_for(build_dir: Path, target: str) -> Path | None: + name = native_executable_name("acecode-desktop" if target == "desktop" else "acecode") + candidates = [ + build_dir / name, + build_dir / "Release" / name, + build_dir / "Debug" / name, + build_dir / "RelWithDebInfo" / name, + build_dir / "MinSizeRel" / name, + ] + for path in candidates: + if path.is_file(): + return path + if not build_dir.is_dir(): + return None + matches = sorted(path for path in build_dir.rglob(name) if path.is_file()) + return matches[0] if matches else None + + +def cmake_cache_value(cache: Path, key: str) -> str | None: + try: + pattern = re.compile(rf"^{re.escape(key)}(?::[^=]+)?=(.*)$") + for line in cache.read_text(encoding="utf-8", errors="replace").splitlines(): + match = pattern.match(line) + if match: + return match.group(1) + except OSError: + return None + return None + + +def cmake_source_dir(build_dir: Path) -> Path | None: + value = cmake_cache_value(build_dir / "CMakeCache.txt", "CMAKE_HOME_DIRECTORY") + return Path(value).resolve() if value else None + + +def configured_for_desktop(build_dir: Path) -> bool: + return cmake_cache_value(build_dir / "CMakeCache.txt", "ACECODE_BUILD_DESKTOP") == "ON" + + +def current_commit(root: Path) -> str | None: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + text=True, + capture_output=True, + check=False, + ) + return result.stdout.strip() if result.returncode == 0 else None + + +def registered_worktrees(root: Path) -> list[Path]: + result = subprocess.run( + ["git", "-C", str(root), "worktree", "list", "--porcelain"], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return [] + return [Path(line[len("worktree "):]).resolve() for line in result.stdout.splitlines() if line.startswith("worktree ")] + + +def build_directories(worktree: Path, explicit: Path | None = None) -> Iterable[Path]: + if explicit: + yield explicit.resolve() + return + build_root = worktree / "build" + if build_root.is_dir(): + yield build_root + yield from (path for path in build_root.iterdir() if path.is_dir()) + + +def platform_matches(build_dir: Path) -> bool: + triplet = cmake_cache_value(build_dir / "CMakeCache.txt", "VCPKG_TARGET_TRIPLET") + if not triplet: + return True + system = platform.system().lower() + expected_system = "windows" if system == "windows" else "osx" if system == "darwin" else "linux" + machine = platform.machine().lower() + expected_arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"amd64", "x86_64"} else None + triplet_lower = triplet.lower() + return expected_system in triplet_lower and (expected_arch is None or expected_arch in triplet_lower) + + +def find_compatible_build(root: Path, target: str, explicit: Path | None = None) -> BuildCandidate | None: + commit = current_commit(root) + if not commit: + return None + worktrees = registered_worktrees(root) + for worktree in worktrees: + if current_commit(worktree) != commit: + continue + directories = [explicit.resolve()] if explicit else build_directories(worktree) + for build_dir in directories: + source_dir = cmake_source_dir(build_dir) + executable = executable_for(build_dir, target) + if source_dir != worktree.resolve() or not executable or not platform_matches(build_dir): + continue + if target == "desktop" and not configured_for_desktop(build_dir): + continue + return BuildCandidate(build_dir.resolve(), source_dir, executable) + return None + + +def default_preset(target: str) -> str | None: + system = platform.system().lower() + machine = platform.machine().lower() + arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"amd64", "x86_64"} else None + if not arch: + return None + prefix = {"windows": "windows", "darwin": "macos", "linux": "linux"}.get(system) + if not prefix: + return None + suffix = "-desktop-release" if target == "desktop" else "-release" + return f"{prefix}-{arch}{suffix}" + + +def ask_to_build(preset: str, target: str, assume_yes: bool) -> bool: + binary_dir = f"build/{preset}" + executable_target = "acecode-desktop" if target == "desktop" else "acecode" + print("[INFO] No compatible build was found in this repository's registered worktrees.") + print(f"[INFO] Proposed configure preset: {preset}") + print(f"[INFO] Proposed build: cmake --build {binary_dir} --target {executable_target}") + if assume_yes: + return True + if not sys.stdin.isatty(): + print("[ERROR] Refusing to compile without interactive confirmation. Re-run with --yes to confirm.", file=sys.stderr) + return False + try: + return input("Configure and build now? [y/N] ").strip().lower() in {"y", "yes"} + except EOFError: + print("[ERROR] Confirmation input was unavailable; no configuration or build was started.", file=sys.stderr) + return False + + +def build_target(root: Path, build_dir: Path, target: str) -> bool: + executable_target = "acecode-desktop" if target == "desktop" else "acecode" + return subprocess.run( + ["cmake", "--build", str(build_dir), "--target", executable_target], + cwd=root, + check=False, + ).returncode == 0 + + +def configure_and_build(root: Path, preset: str, target: str) -> Path | None: + if subprocess.run(["cmake", "--preset", preset], cwd=root, check=False).returncode != 0: + return None + build_dir = root / "build" / preset + return build_dir if build_target(root, build_dir, target) else None + + +def refresh_web_assets(root: Path) -> bool: + script_path = root / "scripts" / "dev_desktop.py" + spec = importlib.util.spec_from_file_location("dev_desktop_for_environment", script_path) + if spec is None or spec.loader is None: + print("[ERROR] Cannot load Web asset builder.", file=sys.stderr) + return False + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + try: + _, pnpm = module.ensure_node_and_pnpm() + module.build_web(root / "web", pnpm) + except (OSError, subprocess.SubprocessError, SystemExit): + return False + return True + + +def worktree_runtime_dir(root: Path) -> Path: + identity = current_commit(root) or root.name + safe = re.sub(r"[^A-Za-z0-9_.-]", "-", f"{root.name}-{identity[:12]}") + return root / ".acecode" / "dev-run" / safe + + +def launch_surface(root: Path, target: str, candidate: BuildCandidate, dry_run: bool, extra: list[str]) -> int: + if target == "web": + run_dir = worktree_runtime_dir(root) + command = [sys.executable, str(root / "scripts" / "dev_web.py"), "--build-dir", str(candidate.build_dir), "--run-dir", str(run_dir), *extra] + print(f"[INFO] Launching Web with build: {candidate.build_dir}") + print(f"[INFO] Isolated runtime directory: {run_dir}") + elif target == "desktop": + command = [sys.executable, str(root / "scripts" / "dev_desktop.py"), "--build-dir", str(candidate.build_dir), "--no-build", *extra] + print(f"[INFO] Launching Desktop with build: {candidate.build_dir}") + else: + command = tui_command(root, candidate.executable) + if command is None: + print(f"[ERROR] Cannot open a new terminal automatically. Run: {candidate.executable}", file=sys.stderr) + return 1 + print(f"[INFO] Launching TUI in a new terminal with build: {candidate.build_dir}") + if dry_run: + print("[INFO] Dry run: " + " ".join(f'"{part}"' if " " in part else part for part in command)) + return 0 + return subprocess.Popen(command, cwd=root).wait() if target == "web" else (subprocess.Popen(command, cwd=root) and 0) + + +def tui_command(root: Path, executable: Path) -> list[str] | None: + if os.name == "nt": + return ["cmd.exe", "/d", "/c", "start", "ACECode TUI", "/d", str(root), str(executable)] + if sys.platform == "darwin": + return ["open", "-a", "Terminal", str(executable)] + for terminal in ("x-terminal-emulator", "gnome-terminal", "konsole", "xterm"): + path = shutil.which(terminal) + if path: + if terminal == "gnome-terminal": + return [path, "--", str(executable)] + if terminal == "konsole": + return [path, "-e", str(executable)] + return [path, "-e", str(executable)] + return None + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Start an ACECode development environment") + parser.add_argument("target", nargs="?", choices=TARGETS, help="development surface to start") + parser.add_argument("--build-dir", type=Path, help="build directory to validate and use") + parser.add_argument("--yes", action="store_true", help="confirm a required CMake build") + parser.add_argument("--dry-run", action="store_true", help="print the selected command without starting it") + args, extra = parser.parse_known_args() + args.extra = extra + return args + + +def choose_target(target: str | None) -> str | None: + if target: + return target + if not sys.stdin.isatty(): + print("[ERROR] Specify one target: web, desktop, or tui.", file=sys.stderr) + return None + selected = input("Choose development target [web/desktop/tui]: ").strip().lower() + if selected not in TARGETS: + print("[ERROR] Choose web, desktop, or tui.", file=sys.stderr) + return None + return selected + + +def main() -> int: + args = parse_args() + target = choose_target(args.target) + if not target: + return 2 + root = project_root() + candidate = find_compatible_build(root, target, args.build_dir) + if candidate is None: + preset = default_preset(target) + if preset is None: + print("[ERROR] No supported CMake preset for this platform and architecture.", file=sys.stderr) + return 1 + if not ask_to_build(preset, target, args.yes): + return 1 + if args.dry_run: + print(f"[INFO] Dry run: cmake --preset {preset}") + return 0 + built = configure_and_build(root, preset, target) + if not built: + return 1 + candidate = find_compatible_build(root, target, built) + if candidate is None: + print("[ERROR] Build completed but did not produce a compatible executable.", file=sys.stderr) + return 1 + if not args.dry_run and not build_target(root, candidate.build_dir, target): + print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) + return 1 + if target in {"web", "desktop"} and not args.dry_run and not refresh_web_assets(root): + print("[ERROR] Web asset refresh failed; development environment was not started.", file=sys.stderr) + return 1 + return launch_surface(root, target, candidate, args.dry_run, args.extra) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/dev_tui.bat b/scripts/dev_tui.bat new file mode 100644 index 00000000..ea35c616 --- /dev/null +++ b/scripts/dev_tui.bat @@ -0,0 +1,24 @@ +@echo off +REM ACECode TUI development launcher (Windows) +REM Usage: scripts\dev_tui.bat [launcher options] + +setlocal +set "SCRIPT_DIR=%~dp0" +call "%SCRIPT_DIR%dev_windows_env.bat" +if errorlevel 1 exit /b %errorlevel% + +where python >nul 2>&1 +if not errorlevel 1 ( + set "PYTHON=python" +) else ( + where py >nul 2>&1 + if not errorlevel 1 ( + set "PYTHON=py" + ) else ( + echo [ERROR] python or py was not found. Install Python 3.8+ first. + exit /b 1 + ) +) + +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" tui %* +exit /b %errorlevel% diff --git a/scripts/dev_tui.sh b/scripts/dev_tui.sh new file mode 100644 index 00000000..effe187e --- /dev/null +++ b/scripts/dev_tui.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# ACECode TUI development launcher (macOS / Linux) +# Usage: ./scripts/dev_tui.sh [launcher options] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if command -v python3 >/dev/null 2>&1; then + PYTHON=python3 +elif command -v python >/dev/null 2>&1; then + PYTHON=python +else + echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2 + exit 1 +fi + +exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" tui "$@" diff --git a/scripts/dev_web.bat b/scripts/dev_web.bat index 8b127d48..202f08ab 100644 --- a/scripts/dev_web.bat +++ b/scripts/dev_web.bat @@ -1,10 +1,11 @@ @echo off -REM ACECode Web UI daemon launcher (no Desktop GUI) -REM Usage: scripts\dev_web.bat [options] -REM Details: python scripts\dev_web.py --help +REM ACECode Web development launcher (Windows) +REM Usage: scripts\dev_web.bat [Web daemon options] setlocal set "SCRIPT_DIR=%~dp0" +call "%SCRIPT_DIR%dev_windows_env.bat" +if errorlevel 1 exit /b %errorlevel% where python >nul 2>&1 if not errorlevel 1 ( @@ -19,5 +20,5 @@ if not errorlevel 1 ( ) ) -"%PYTHON%" "%SCRIPT_DIR%dev_web.py" %* +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" web %* exit /b %errorlevel% diff --git a/scripts/dev_web.sh b/scripts/dev_web.sh new file mode 100644 index 00000000..6a76b9cc --- /dev/null +++ b/scripts/dev_web.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# ACECode Web development launcher (macOS / Linux) +# Usage: ./scripts/dev_web.sh [Web daemon options] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if command -v python3 >/dev/null 2>&1; then + PYTHON=python3 +elif command -v python >/dev/null 2>&1; then + PYTHON=python +else + echo "[ERROR] python3 or python was not found. Install Python 3.8+ first." >&2 + exit 1 +fi + +exec "$PYTHON" "$SCRIPT_DIR/dev_environment.py" web "$@" diff --git a/scripts/dev_windows_env.bat b/scripts/dev_windows_env.bat new file mode 100644 index 00000000..69478ca5 --- /dev/null +++ b/scripts/dev_windows_env.bat @@ -0,0 +1,37 @@ +@echo off +REM Initialize the Visual Studio x64 C++ developer environment for dev launchers. + +setlocal EnableDelayedExpansion +set "ACECODE_VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +if not exist "!ACECODE_VSWHERE!" goto :missing_installer + +set "ACECODE_VSINSTALL=" +for /f "usebackq delims=" %%I in (`"!ACECODE_VSWHERE!" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "ACECODE_VSINSTALL=%%I" +if not defined ACECODE_VSINSTALL goto :missing_tools + +set "ACECODE_VSDEVCMD=!ACECODE_VSINSTALL!\Common7\Tools\VsDevCmd.bat" +if not exist "!ACECODE_VSDEVCMD!" goto :missing_command + +for %%I in ("!ACECODE_VSDEVCMD!") do endlocal & set "ACECODE_VSDEVCMD=%%~fI" +call "%ACECODE_VSDEVCMD%" -arch=amd64 -host_arch=amd64 >nul +if errorlevel 1 goto :initialization_failed +exit /b 0 + +:missing_installer +endlocal +echo [ERROR] Visual Studio Installer was not found. Install Visual Studio Build Tools with the Desktop development with C++ workload. +exit /b 1 + +:missing_tools +endlocal +echo [ERROR] Visual Studio C++ tools were not found. Install the Desktop development with C++ workload. +exit /b 1 + +:missing_command +endlocal +echo [ERROR] Visual Studio developer command script was not found. +exit /b 1 + +:initialization_failed +echo [ERROR] Failed to initialize the Visual Studio C++ developer environment. +exit /b 1 diff --git a/tests/scripts/dev_environment_test.py b/tests/scripts/dev_environment_test.py new file mode 100644 index 00000000..787cab5f --- /dev/null +++ b/tests/scripts/dev_environment_test.py @@ -0,0 +1,127 @@ +import importlib.util +import os +from pathlib import Path +import sys +import tempfile +import subprocess +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("dev_environment", ROOT / "scripts/dev_environment.py") +dev_environment = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = dev_environment +spec.loader.exec_module(dev_environment) + + +class DevEnvironmentTest(unittest.TestCase): + def make_build(self, root, desktop=False): + build = root / "build" / "test" + build.mkdir(parents=True) + (build / "CMakeCache.txt").write_text( + "CMAKE_HOME_DIRECTORY:INTERNAL=" + str(root) + "\n" + "VCPKG_TARGET_TRIPLET:STRING=x64-windows-static\n" + f"ACECODE_BUILD_DESKTOP:BOOL={'ON' if desktop else 'OFF'}\n", + encoding="utf-8", + ) + (build / dev_environment.native_executable_name("acecode")).touch() + if desktop: + (build / dev_environment.native_executable_name("acecode-desktop")).touch() + return build + + def test_candidate_requires_matching_source_commit_and_executable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "current" + other = Path(directory) / "other" + root.mkdir() + other.mkdir() + build = self.make_build(other) + with patch.object(dev_environment, "current_commit", side_effect=lambda path: "same" if path == root or path == other else None), \ + patch.object(dev_environment, "registered_worktrees", return_value=[root, other]), \ + patch.object(dev_environment, "platform_matches", return_value=True): + candidate = dev_environment.find_compatible_build(root, "web") + self.assertIsNotNone(candidate) + self.assertEqual(candidate.build_dir, build.resolve()) + + def test_registered_worktrees_parses_porcelain_prefix(self): + root = Path("C:/work") + output = "worktree C:/work\nHEAD abc\n\nworktree C:/other\nHEAD def\n" + completed = subprocess.CompletedProcess([], 0, stdout=output) + with patch.object(dev_environment.subprocess, "run", return_value=completed): + self.assertEqual(dev_environment.registered_worktrees(root), [Path("C:/work"), Path("C:/other")]) + + def test_desktop_candidate_requires_desktop_configuration_and_executable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + build = self.make_build(root, desktop=False) + with patch.object(dev_environment, "current_commit", return_value="same"), \ + patch.object(dev_environment, "registered_worktrees", return_value=[root]), \ + patch.object(dev_environment, "platform_matches", return_value=True): + self.assertIsNone(dev_environment.find_compatible_build(root, "desktop")) + self.assertTrue((build / dev_environment.native_executable_name("acecode")).exists()) + + def test_tui_command_uses_new_windows_terminal(self): + executable = Path("C:/work/build/acecode.exe") + with patch.object(dev_environment.os, "name", "nt"): + command = dev_environment.tui_command(Path("C:/work"), executable) + self.assertEqual(command[:4], ["cmd.exe", "/d", "/c", "start"]) + self.assertIn(str(executable), command) + + def test_runtime_directory_is_scoped_to_worktree(self): + root = Path("C:/worktrees/my project") + with patch.object(dev_environment, "current_commit", return_value="abcdef1234567890"): + runtime = dev_environment.worktree_runtime_dir(root) + self.assertEqual(runtime, root / ".acecode/dev-run/my-project-abcdef123456") + + def test_noninteractive_target_selection_fails(self): + with patch.object(dev_environment.sys.stdin, "isatty", return_value=False): + self.assertIsNone(dev_environment.choose_target(None)) + + def test_main_refreshes_build_and_web_assets_before_web_start(self): + candidate = dev_environment.BuildCandidate( + Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") + ) + args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() + with patch.object(dev_environment, "parse_args", return_value=args), \ + patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ + patch.object(dev_environment, "find_compatible_build", return_value=candidate), \ + patch.object(dev_environment, "build_target", return_value=True) as build, \ + patch.object(dev_environment, "refresh_web_assets", return_value=True) as refresh, \ + patch.object(dev_environment, "launch_surface", return_value=0) as launch: + self.assertEqual(dev_environment.main(), 0) + build.assert_called_once_with(Path("C:/work"), candidate.build_dir, "web") + refresh.assert_called_once_with(Path("C:/work")) + launch.assert_called_once_with(Path("C:/work"), "web", candidate, False, []) + + def test_confirmation_eof_refuses_configuration(self): + with patch.object(dev_environment.sys.stdin, "isatty", return_value=True), \ + patch("builtins.input", side_effect=EOFError): + self.assertFalse(dev_environment.ask_to_build("windows-x64-release", "web", False)) + + def test_web_launch_forwards_the_build_and_isolated_runtime_directory(self): + candidate = dev_environment.BuildCandidate( + Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") + ) + with patch.object(dev_environment, "worktree_runtime_dir", return_value=Path("C:/work/.acecode/dev-run/test")): + result = dev_environment.launch_surface(Path("C:/work"), "web", candidate, dry_run=True, extra=[]) + self.assertEqual(result, 0) + + @unittest.skipUnless(os.name == "nt", "Windows batch wrapper") + def test_batch_wrapper_delegates_to_python(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "py.bat").write_text("@echo off\necho fake-python-launcher\nexit /b 7\n", encoding="ascii") + environment = dict(os.environ) + environment["PATH"] = str(root) + os.pathsep + str(Path(os.environ["SystemRoot"]) / "System32") + result = subprocess.run( + [os.environ["COMSPEC"], "/d", "/c", str(ROOT / "scripts/dev_web.bat"), "--help"], + cwd=root, env=environment, capture_output=True, text=True, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + self.assertEqual(result.returncode, 7, result.stdout + result.stderr) + self.assertIn("fake-python-launcher", result.stdout) + + +if __name__ == "__main__": + unittest.main() From a7a29ba006b3684e3b40b8d82d5f63c54d828095 Mon Sep 17 00:00:00 2001 From: Trae User Date: Sat, 19 Sep 2026 14:17:01 +0800 Subject: [PATCH 2/7] feat: accelerate worktree development builds --- .../skills/development-environment/SKILL.md | 5 +- .../design.md | 6 +- .../proposal.md | 1 + .../development-environment-launcher/spec.md | 9 +- .../tasks.md | 3 +- .../.openspec.yaml | 2 + .../add-worktree-build-acceleration/design.md | 60 +++++ .../proposal.md | 30 +++ .../specs/worktree-build-acceleration/spec.md | 55 +++++ .../add-worktree-build-acceleration/tasks.md | 17 ++ scripts/dev_desktop.bat | 2 +- scripts/dev_environment.py | 222 ++++++++++++++++-- scripts/dev_tui.bat | 2 +- scripts/dev_web.bat | 2 +- tests/scripts/dev_environment_test.py | 72 +++++- 15 files changed, 463 insertions(+), 25 deletions(-) create mode 100644 openspec/changes/add-worktree-build-acceleration/.openspec.yaml create mode 100644 openspec/changes/add-worktree-build-acceleration/design.md create mode 100644 openspec/changes/add-worktree-build-acceleration/proposal.md create mode 100644 openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md create mode 100644 openspec/changes/add-worktree-build-acceleration/tasks.md diff --git a/.agents/skills/development-environment/SKILL.md b/.agents/skills/development-environment/SKILL.md index d1cf3713..d47b189c 100644 --- a/.agents/skills/development-environment/SKILL.md +++ b/.agents/skills/development-environment/SKILL.md @@ -45,9 +45,8 @@ Every launch incrementally builds the verified target, so source changes are inc If no compatible configured build exists, the launcher reports the platform CMake preset and asks for confirmation before configuration. Preserve that safety boundary: -- State that the launcher needs to configure and build the selected target and name the preset. -- Ask the user for explicit confirmation before adding `--yes`. -- After confirmation, run the same target-specific launcher with `--yes`. +- Windows target-specific batch launchers automatically approve this first configuration so they work when double-clicked. +- For the shared Python launcher and POSIX target-specific launchers, state that configuration is needed, name the preset, and ask the user for explicit confirmation before adding `--yes`. - If the user declines, do not configure, compile, or start a surface. The shared launcher calls the existing Python surface launchers: `scripts/dev_web.py` for Web and `scripts/dev_desktop.py` for Desktop. Web uses a worktree-isolated runtime directory and opens its resulting local URL; Desktop opens its application window; TUI opens a new terminal window. diff --git a/openspec/changes/add-development-environment-launcher/design.md b/openspec/changes/add-development-environment-launcher/design.md index 945dfe46..a7df8330 100644 --- a/openspec/changes/add-development-environment-launcher/design.md +++ b/openspec/changes/add-development-environment-launcher/design.md @@ -49,6 +49,10 @@ Alternatives considered: - Reimplement Web and Desktop startup in the new tool: would create two sources of truth for Web assets and Desktop development-mode behavior. - Run TUI in the current terminal: conflicts with the agreed ability to continue launcher work after opening TUI. +### Auto-configure missing builds from Windows direct entry points + +Double-clicked batch files do not provide a reliable input stream for the shared launcher's confirmation prompt. Each Windows target entry point will therefore append `--yes` when it calls `dev_environment.py`, approving only the missing-build configuration path. The shared Python launcher remains conservative for callers that invoke it directly, and POSIX wrappers retain their interactive confirmation behavior. + ### Initialize the Windows C++ toolchain in batch entry points The three Windows target wrappers will call a shared batch helper before invoking Python. The helper locates `vswhere.exe` from the Visual Studio installer location, asks it for an installation containing the x64 C++ tools component, and calls that installation's `VsDevCmd.bat` with `-arch=amd64 -host_arch=amd64`. It preserves the caller's command context while supplying the standard-library and linker paths that CMake needs. If discovery fails, it reports the Build Tools C++ workload requirement and exits before Python runs. @@ -60,7 +64,7 @@ Alternatives considered: ### Require interactive confirmation only for a new configuration -When discovery cannot produce a compatible result, the tool calculates the native CMake preset for the selected target and prints configure/build commands. It asks a yes/no question only when stdin is interactive; non-interactive invocations fail with the same instructions rather than implicitly configuring. Once a build directory has been validated or configured, the target's incremental build runs without another confirmation so each launch reflects current sources. +When discovery cannot produce a compatible result, the tool calculates the native CMake preset for the selected target and prints configure/build commands. It asks a yes/no question only when stdin is interactive; non-interactive invocations fail with the same instructions rather than implicitly configuring. Once a build directory has been validated or configured, the target's incremental build runs without another confirmation so each launch reflects current sources. First-time development configurations pass `-DBUILD_TESTING=OFF`, because unit-test dependencies are optional in the vcpkg manifest and are not needed to run a development surface. Alternatives considered: diff --git a/openspec/changes/add-development-environment-launcher/proposal.md b/openspec/changes/add-development-environment-launcher/proposal.md index 0220c4cf..020345bd 100644 --- a/openspec/changes/add-development-environment-launcher/proposal.md +++ b/openspec/changes/add-development-environment-launcher/proposal.md @@ -11,6 +11,7 @@ Developers currently need to choose and invoke separate Web or Desktop launchers - Discover related worktrees through Git and validate a reusable build against the current source revision, platform, architecture, and requested target before using it. - Incrementally rebuild every verified build before launch so source changes are incorporated; require confirmation only before configuring a missing or incompatible build. - Initialize the Windows Visual Studio C++ developer environment from the direct launchers so incremental builds work from a normal shell or double-clicked batch file. +- Allow Windows direct launchers to configure a missing target build automatically, so double-clicked entry points do not wait for unavailable confirmation input. - Give each Web development workspace an isolated daemon runtime directory and open the selected development surface after a successful start. - Add a repository-local skill that asks for a target when omitted and applies the same validation and launch policy. diff --git a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md index 3da4968b..9096a83b 100644 --- a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md +++ b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md @@ -29,7 +29,7 @@ Before requesting a rebuild, the launcher and skill SHALL search the Git worktre - **THEN** the launcher does not use it for the Desktop target ### Requirement: Fresh incremental builds and configuration confirmation -Before starting a selected target from a compatible build directory, the launcher SHALL run that target's incremental CMake build so changed source files are incorporated. Before starting Web or Desktop, it SHALL also ensure the development frontend assets are current. When no compatible configured build exists, the launcher and skill SHALL report the missing requirement and the CMake preset selected for the current platform, then obtain explicit developer confirmation before configuring a new build. A declined confirmation SHALL leave source and build files unchanged and SHALL not start a development target. +Before starting a selected target from a compatible build directory, the launcher SHALL run that target's incremental CMake build so changed source files are incorporated. Before starting Web or Desktop, it SHALL also ensure the development frontend assets are current. When no compatible configured build exists, the launcher and skill SHALL report the missing requirement and the CMake preset selected for the current platform, then configure the development target with testing disabled so optional unit-test dependencies do not block startup. They SHALL obtain explicit developer confirmation before configuring a new build unless a Windows direct entry point supplies its automatic approval. A declined confirmation SHALL leave source and build files unchanged and SHALL not start a development target. #### Scenario: Refresh a compatible build - **WHEN** a compatible build exists and source files have changed @@ -43,6 +43,13 @@ Before starting a selected target from a compatible build directory, the launche - **WHEN** the selected target has no compatible build and the developer declines the proposed configuration - **THEN** the launcher exits without configuring, compiling, or starting a target +### Requirement: Windows direct-launch configuration +Windows target-specific batch entry points SHALL pass automatic configuration approval to the shared launcher. When no compatible configured build exists, those direct entry points SHALL configure and build it without requiring console input. The shared Python launcher and POSIX direct entry points SHALL retain explicit confirmation requirements for a missing build. + +#### Scenario: Double-clicked Web launcher requires a first build +- **WHEN** a developer starts the Windows Web batch entry point and no compatible configured build exists +- **THEN** it configures, builds, and starts the Web target without waiting for confirmation input + ### Requirement: Windows compiler environment initialization Before a Windows target-specific entry point runs an incremental CMake build, it SHALL initialize an installed Visual Studio C++ developer environment for the host and target architecture. If no suitable Visual Studio C++ tools installation is available, it SHALL exit before invoking the shared launcher and report how to install the required Build Tools workload. diff --git a/openspec/changes/add-development-environment-launcher/tasks.md b/openspec/changes/add-development-environment-launcher/tasks.md index b147425e..27b6abb0 100644 --- a/openspec/changes/add-development-environment-launcher/tasks.md +++ b/openspec/changes/add-development-environment-launcher/tasks.md @@ -15,4 +15,5 @@ ## 3. Windows toolchain and verification - [x] 3.1 Add a shared Windows Visual Studio developer-environment helper and invoke it from each target-specific batch entry point; verify a normal shell receives C++ compiler include paths. -- [x] 3.2 Extend focused launcher tests for toolchain initialization, incremental builds, frontend refresh, and target-specific wrappers; run them with Python compilation checks and `openspec validate add-development-environment-launcher --strict`. +- [x] 3.2 Make Windows target-specific entry points automatically approve a missing-build configuration, while preserving explicit confirmation for Python and POSIX callers; verify wrapper argument forwarding and focused launcher tests. +- [x] 3.3 Run Python compilation checks and `openspec validate add-development-environment-launcher --strict`. diff --git a/openspec/changes/add-worktree-build-acceleration/.openspec.yaml b/openspec/changes/add-worktree-build-acceleration/.openspec.yaml new file mode 100644 index 00000000..eaa6b1cd --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-19 diff --git a/openspec/changes/add-worktree-build-acceleration/design.md b/openspec/changes/add-worktree-build-acceleration/design.md new file mode 100644 index 00000000..12e93512 --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/design.md @@ -0,0 +1,60 @@ +# Design + +## Context + +See `proposal.md` for motivation and `specs/worktree-build-acceleration/spec.md` for requirements. `scripts/dev_environment.py` currently validates builds against Git worktrees, keeps build directories independent, incrementally builds the selected target, and calls the Desktop launcher module to refresh frontend assets. Its first-worktree build compiles all ACECode sources even when a related worktree has already compiled identical content. + +## Goals / Non-Goals + +**Goals:** + +- Preserve one CMake/Ninja build directory per worktree and selected preset. +- Reuse content-addressed compiler results through the user's existing `sccache` cache. +- Reuse a verified frontend production output only when Git and working-tree state make it safe. +- Make all acceleration paths optional and self-healing through ordinary local builds. + +**Non-Goals:** + +- Copy, link, or share CMake caches, Ninja files, object files, executables, or `node_modules` between worktrees. +- Download or install `sccache` automatically. +- Change production builds or require compiler caching for any developer. + +## Decisions + +### Configure sccache through CMake compiler launchers + +The launcher finds `sccache` from `PATH` plus conventional platform-specific package-manager installation directories. When executable, it passes `CMAKE_C_COMPILER_LAUNCHER` and `CMAKE_CXX_COMPILER_LAUNCHER` only during CMake configuration. A launcher-owned cache-state marker in the build directory records whether caching was successfully configured, allowing the next launch to decide whether it must reconfigure when availability changes. + +Alternatives considered: + +- Set process-wide compiler environment variables: harder to audit and can affect unrelated commands. +- Use `clcache`: works for MSVC but needs a separate implementation for other supported platforms. +- Share the build directory: unsafe because generated build metadata stores absolute worktree paths. + +### Treat cache instrumentation as best effort + +The launcher invokes `sccache --show-stats --stats-format=json` before and after building when supported, computes a delta for cache hits, misses, and errors, and prints it. Parsing or command failures disable only reporting; cache configuration failures trigger a CMake reconfiguration without launchers and continue with normal compilation. + +### Copy frontend artifacts, never link them + +The launcher considers registered Git worktrees at the same commit. It excludes a worktree if `git status --porcelain -- web` has output. It selects the newest valid `web/dist/index.html`, copies the entire `web/dist` tree into the current worktree, and invokes the existing freshness predicate. Linking would let a build in one worktree mutate another's assets; copying preserves ownership after the seed step. + +Alternatives considered: + +- Trust commit equality alone: local frontend modifications could make a copied artifact stale. +- Hash all frontend input files: stricter but slower and unnecessary once both trees are clean at the same commit. +- Require `node_modules`: not needed to serve a verified copied output. + +## Risks / Trade-offs + +- [A cache tool installs in an unrecognized path] → use `PATH` first, document the optional install hint, and safely fall back. +- [Other builds change global sccache statistics] → label figures as per-launch observed deltas rather than exact attribution. +- [A copied artifact becomes stale while copying] → compare frontend input modification times after the copy; local Vite build remains the fallback. +- [Cache configuration fails after a previously successful configuration] → reconfigure without launchers and continue the requested build. + +## Migration Plan + +1. Add pure helpers for cache discovery/state and frontend seed eligibility, with focused tests. +2. Integrate helpers into first-time and existing-build configuration flows. +3. Run the Web launcher in a clean worktree with and without `sccache`, confirming both fallback and accelerated paths. +4. Roll back by removing launcher cache settings and seed logic; all build directories and frontend output remain usable through the existing local build flow. diff --git a/openspec/changes/add-worktree-build-acceleration/proposal.md b/openspec/changes/add-worktree-build-acceleration/proposal.md new file mode 100644 index 00000000..49b5d045 --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/proposal.md @@ -0,0 +1,30 @@ +# Proposal + +## Why + +New Git worktrees require independent CMake build directories for correctness, but compiling every unchanged ACECode source file and rebuilding identical frontend output makes their first development launch unnecessarily slow. The development launcher should safely reuse cross-worktree caches and verified frontend output without sharing path-bound build directories. + +## What Changes + +- Detect and configure `sccache` as an optional cross-worktree C/C++ compiler cache for development builds. +- Report concise per-launch cache activity and fall back to normal compilation when cache discovery, configuration, or status collection fails. +- Detect cache-tool configuration changes and reconfigure the affected build directory before its next incremental build. +- Seed a new worktree's missing or stale `web/dist` from the newest eligible, clean, same-commit worktree and validate it before skipping Vite. +- Fall back to the existing local frontend build process if frontend artifact reuse is unavailable or fails. + +## Capabilities + +### New Capabilities + +- `worktree-build-acceleration`: Safe cross-worktree compiler-cache and frontend-artifact reuse for development launcher startup. + +### Modified Capabilities + +- None. + +## Impact + +- `scripts/dev_environment.py` gains compiler-cache detection, CMake configuration management, and frontend seed selection. +- `scripts/dev_desktop.py` exposes or shares frontend freshness checks used by the launcher. +- Launcher tests cover cache discovery/configuration transitions and frontend-copy eligibility/fallback. +- No production application runtime, daemon API, or shared CMake build directory behavior changes. diff --git a/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md b/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md new file mode 100644 index 00000000..bd5db6fe --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md @@ -0,0 +1,55 @@ +# Spec Delta + +## Purpose + +Accelerate first development launches in new worktrees without sharing build directories that encode worktree-specific source paths. + +## ADDED Requirements + +### Requirement: Optional shared compiler cache +The development launcher SHALL detect an available `sccache` executable using the system path and supported platform-specific installation locations. When available, it SHALL configure C and C++ compiler launchers for a development build directory so worktrees may reuse the user's default `sccache` cache. When unavailable or unusable, the launcher SHALL continue with an ordinary local compilation and provide a concise status message with an installation suggestion when the cache is absent. + +#### Scenario: Configure an available compiler cache +- **WHEN** a launcher prepares a configured development build and finds a usable `sccache` executable +- **THEN** it configures C and C++ compiler launchers to use `sccache` before the target's incremental build + +#### Scenario: Cache tool unavailable +- **WHEN** a launcher cannot find `sccache` +- **THEN** it reports an optional installation suggestion and continues with normal compilation + +#### Scenario: Cache tool failure +- **WHEN** compiler-cache configuration, execution, or statistics collection fails +- **THEN** the launcher reports a concise reason and falls back to ordinary compilation without blocking target startup + +### Requirement: Cache configuration transitions +The development launcher SHALL detect when an existing build directory's configured compiler-cache state differs from the currently available cache state. It SHALL reconfigure that build directory before its next incremental build to add or remove compiler launcher settings as required. + +#### Scenario: Install cache after initial configuration +- **WHEN** `sccache` becomes available after a build directory was configured without it +- **THEN** the next development launch reconfigures the build directory before building + +#### Scenario: Remove cache after configuration +- **WHEN** `sccache` is no longer available for a build directory configured to use it +- **THEN** the next development launch reconfigures the build directory without compiler launcher settings before building + +### Requirement: Per-launch compiler-cache status +When `sccache` is used successfully, the development launcher SHALL report a concise summary of the cache hits, misses, and errors observed during that launch's build operation. It SHALL not require cache statistics to start a development target. + +#### Scenario: Report build cache activity +- **WHEN** the cache statistics can be read before and after a development build +- **THEN** the launcher reports the per-launch differences for hits, misses, and errors + +### Requirement: Verified frontend artifact seed +Before a Web or Desktop development launch builds frontend assets, the launcher SHALL look for a seed `web/dist` from registered Git worktrees. It MAY copy a seed only when the source and current worktrees are at the same commit, both have no uncommitted changes under `web/`, the source has `web/dist/index.html`, and the current output is missing or stale. If more than one eligible source exists, it SHALL select the source with the newest `web/dist/index.html`. It SHALL validate copied output against current frontend input timestamps before skipping the local frontend build. + +#### Scenario: Seed missing frontend output +- **WHEN** a new worktree has no `web/dist`, and an eligible same-commit worktree has current frontend output +- **THEN** the launcher copies and validates the newest eligible output, then skips `pnpm build` + +#### Scenario: Dirty frontend worktree +- **WHEN** either the source or current worktree has uncommitted changes under `web/` +- **THEN** the launcher does not reuse that source's frontend output + +#### Scenario: Seed copy failure +- **WHEN** frontend artifact copying or validation fails +- **THEN** the launcher reports the fallback and runs the ordinary local frontend build diff --git a/openspec/changes/add-worktree-build-acceleration/tasks.md b/openspec/changes/add-worktree-build-acceleration/tasks.md new file mode 100644 index 00000000..be67221e --- /dev/null +++ b/openspec/changes/add-worktree-build-acceleration/tasks.md @@ -0,0 +1,17 @@ +# Tasks + +## 1. Compiler-cache integration + +- [x] 1.1 Add cross-platform `sccache` discovery, installation guidance, and cache-state helpers; verify focused unit tests cover PATH, conventional paths, and absent-cache fallback. +- [x] 1.2 Configure or remove CMake compiler launchers when cache availability changes, with ordinary-build fallback on cache errors; verify subprocess command tests cover both transitions. +- [x] 1.3 Collect and display best-effort per-launch `sccache` hit, miss, and error deltas; verify statistic parsing and failure fallback with focused tests. + +## 2. Frontend artifact reuse + +- [x] 2.1 Implement clean same-commit worktree eligibility and newest `web/dist` source selection; verify dirty, mismatched-commit, missing-output, and newest-source cases. +- [x] 2.2 Copy and validate a frontend seed before the local Vite build, with local-build fallback on copy or freshness failure; verify focused tests cover successful skip and fallback. + +## 3. Integration and verification + +- [x] 3.1 Wire compiler-cache and frontend-seed behavior into Web and Desktop launcher flows while preserving isolated build directories; verify focused launcher tests pass. +- [x] 3.2 Run Python compilation, OpenSpec strict validation, and an end-to-end Web startup with cache absent or present; verify daemon page reachability and report the observed acceleration path. diff --git a/scripts/dev_desktop.bat b/scripts/dev_desktop.bat index 83b9e080..7fa1fc73 100644 --- a/scripts/dev_desktop.bat +++ b/scripts/dev_desktop.bat @@ -20,5 +20,5 @@ if not errorlevel 1 ( ) ) -"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" desktop %* +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" desktop --yes %* exit /b %errorlevel% diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py index aab8ccf1..8f959f83 100644 --- a/scripts/dev_environment.py +++ b/scripts/dev_environment.py @@ -4,9 +4,10 @@ from __future__ import annotations import argparse +import importlib.util +import json import os import platform -import importlib.util import re import shutil import subprocess @@ -16,6 +17,7 @@ from typing import Iterable TARGETS = ("web", "desktop", "tui") +CACHE_MARKER = ".acecode-sccache.json" @dataclass(frozen=True) @@ -153,7 +155,7 @@ def ask_to_build(preset: str, target: str, assume_yes: bool) -> bool: binary_dir = f"build/{preset}" executable_target = "acecode-desktop" if target == "desktop" else "acecode" print("[INFO] No compatible build was found in this repository's registered worktrees.") - print(f"[INFO] Proposed configure preset: {preset}") + print(f"[INFO] Proposed configure preset: {preset} (BUILD_TESTING=OFF)") print(f"[INFO] Proposed build: cmake --build {binary_dir} --target {executable_target}") if assume_yes: return True @@ -167,30 +169,198 @@ def ask_to_build(preset: str, target: str, assume_yes: bool) -> bool: return False -def build_target(root: Path, build_dir: Path, target: str) -> bool: +def sccache_candidates() -> list[Path]: + executable = "sccache.exe" if os.name == "nt" else "sccache" + candidates: list[Path] = [] + from_path = shutil.which("sccache") + if from_path: + candidates.append(Path(from_path)) + if os.name == "nt": + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + candidates.extend([ + Path(local_app_data) / "Microsoft" / "WinGet" / "Links" / executable, + Path(local_app_data) / "scoop" / "shims" / executable, + ]) + chocolatey = os.environ.get("ChocolateyInstall", r"C:\\ProgramData\\chocolatey") + candidates.append(Path(chocolatey) / "bin" / executable) + elif sys.platform == "darwin": + candidates.extend([Path("/opt/homebrew/bin/sccache"), Path("/usr/local/bin/sccache")]) + else: + candidates.append(Path.home() / ".cargo" / "bin" / "sccache") + return candidates + + +def find_sccache() -> Path | None: + for candidate in sccache_candidates(): + if candidate.is_file(): + return candidate.resolve() + return None + + +def sccache_install_hint() -> str: + if os.name == "nt": + return "Install sccache with: winget install Mozilla.sccache" + if sys.platform == "darwin": + return "Install sccache with: brew install sccache" + return "Install sccache with your package manager or cargo install sccache" + + +def cache_marker(build_dir: Path) -> Path: + return build_dir / CACHE_MARKER + + +def cached_sccache_path(build_dir: Path) -> str | None: + try: + data = json.loads(cache_marker(build_dir).read_text(encoding="utf-8")) + value = data.get("sccache") + return value if isinstance(value, str) else None + except (OSError, ValueError, TypeError): + return None + + +def write_sccache_marker(build_dir: Path, sccache: Path | None) -> None: + build_dir.mkdir(parents=True, exist_ok=True) + cache_marker(build_dir).write_text(json.dumps({"sccache": str(sccache) if sccache else None}) + "\n", encoding="utf-8") + + +def cache_state_changed(build_dir: Path, sccache: Path | None) -> bool: + return cached_sccache_path(build_dir) != (str(sccache) if sccache else None) + + +def configure_build(root: Path, preset: str, build_dir: Path, sccache: Path | None) -> bool: + command = ["cmake", "--preset", preset, "-DBUILD_TESTING=OFF"] + if sccache: + command.extend([ + f"-DCMAKE_C_COMPILER_LAUNCHER={sccache}", + f"-DCMAKE_CXX_COMPILER_LAUNCHER={sccache}", + ]) + else: + command.extend(["-DCMAKE_C_COMPILER_LAUNCHER=", "-DCMAKE_CXX_COMPILER_LAUNCHER="]) + if subprocess.run(command, cwd=root, check=False).returncode != 0: + return False + write_sccache_marker(build_dir, sccache) + return True + + +def sccache_stats(sccache: Path) -> dict[str, int] | None: + result = subprocess.run([str(sccache), "--show-stats", "--stats-format=json"], text=True, capture_output=True, check=False) + if result.returncode != 0: + return None + try: + data = json.loads(result.stdout) + except ValueError: + return None + flat = json.dumps(data).lower() + values = {"hits": 0, "misses": 0, "errors": 0} + def collect(value): + if isinstance(value, dict): + for key, child in value.items(): + normalized = key.lower().replace("_", " ") + if isinstance(child, int): + if "hit" in normalized: + values["hits"] += child + elif "miss" in normalized: + values["misses"] += child + elif "error" in normalized: + values["errors"] += child + else: + collect(child) + elif isinstance(value, list): + for child in value: + collect(child) + collect(data) + return values if flat else None + + +def report_sccache_delta(before: dict[str, int] | None, after: dict[str, int] | None) -> None: + if before is None or after is None: + print("[INFO] sccache statistics unavailable; continuing.") + return + delta = {key: max(0, after[key] - before[key]) for key in before} + print(f"[INFO] sccache this build: hits={delta['hits']} misses={delta['misses']} errors={delta['errors']}") + + +def build_target(root: Path, build_dir: Path, target: str, sccache: Path | None = None) -> bool: executable_target = "acecode-desktop" if target == "desktop" else "acecode" - return subprocess.run( + before = sccache_stats(sccache) if sccache else None + result = subprocess.run( ["cmake", "--build", str(build_dir), "--target", executable_target], cwd=root, check=False, - ).returncode == 0 + ) + if sccache: + report_sccache_delta(before, sccache_stats(sccache)) + return result.returncode == 0 -def configure_and_build(root: Path, preset: str, target: str) -> Path | None: - if subprocess.run(["cmake", "--preset", preset], cwd=root, check=False).returncode != 0: - return None +def configure_and_build(root: Path, preset: str, target: str, sccache: Path | None = None) -> Path | None: build_dir = root / "build" / preset - return build_dir if build_target(root, build_dir, target) else None + if not configure_build(root, preset, build_dir, sccache): + return None + return build_dir if build_target(root, build_dir, target, sccache) else None -def refresh_web_assets(root: Path) -> bool: +def web_worktree_is_clean(root: Path) -> bool: + result = subprocess.run(["git", "-C", str(root), "status", "--porcelain", "--", "web"], text=True, capture_output=True, check=False) + return result.returncode == 0 and not result.stdout.strip() + + +def newest_web_seed(root: Path) -> Path | None: + commit = current_commit(root) + if not commit or not web_worktree_is_clean(root): + return None + candidates: list[Path] = [] + for worktree in registered_worktrees(root): + if worktree == root.resolve() or current_commit(worktree) != commit or not web_worktree_is_clean(worktree): + continue + index = worktree / "web" / "dist" / "index.html" + if index.is_file(): + candidates.append(index) + return max(candidates, key=lambda item: item.stat().st_mtime, default=None) + + +def load_web_builder(root: Path): script_path = root / "scripts" / "dev_desktop.py" spec = importlib.util.spec_from_file_location("dev_desktop_for_environment", script_path) if spec is None or spec.loader is None: - print("[ERROR] Cannot load Web asset builder.", file=sys.stderr) - return False + return None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) + return module + + +def seed_web_assets(root: Path, builder) -> bool: + web_dir = root / "web" + index = web_dir / "dist" / "index.html" + if not builder.web_build_is_stale(web_dir, index): + return False + seed_index = newest_web_seed(root) + if seed_index is None: + return False + try: + if (web_dir / "dist").exists(): + shutil.rmtree(web_dir / "dist") + shutil.copytree(seed_index.parent, web_dir / "dist") + if builder.web_build_is_stale(web_dir, index): + print("[INFO] Reused Web assets were stale; falling back to local build.") + shutil.rmtree(web_dir / "dist", ignore_errors=True) + return False + print(f"[INFO] Reused Web assets from: {seed_index.parent.parent.parent}") + return True + except OSError as error: + print(f"[INFO] Could not reuse Web assets ({error}); falling back to local build.") + shutil.rmtree(web_dir / "dist", ignore_errors=True) + return False + + +def refresh_web_assets(root: Path) -> bool: + module = load_web_builder(root) + if module is None: + print("[ERROR] Cannot load Web asset builder.", file=sys.stderr) + return False + if seed_web_assets(root, module): + return True try: _, pnpm = module.ensure_node_and_pnpm() module.build_web(root / "web", pnpm) @@ -272,6 +442,11 @@ def main() -> int: if not target: return 2 root = project_root() + sccache = find_sccache() + if sccache: + print(f"[INFO] Using sccache: {sccache}") + else: + print(f"[INFO] sccache not found. {sccache_install_hint()}") candidate = find_compatible_build(root, target, args.build_dir) if candidate is None: preset = default_preset(target) @@ -283,14 +458,31 @@ def main() -> int: if args.dry_run: print(f"[INFO] Dry run: cmake --preset {preset}") return 0 - built = configure_and_build(root, preset, target) + built = configure_and_build(root, preset, target, sccache) if not built: - return 1 + if sccache: + print("[INFO] sccache configuration failed; retrying normal compilation.") + built = configure_and_build(root, preset, target, None) + if not built: + return 1 candidate = find_compatible_build(root, target, built) if candidate is None: print("[ERROR] Build completed but did not produce a compatible executable.", file=sys.stderr) return 1 - if not args.dry_run and not build_target(root, candidate.build_dir, target): + if not args.dry_run and cache_state_changed(candidate.build_dir, sccache): + preset = default_preset(target) + if preset is None: + print("[ERROR] Cannot reconfigure the build for this platform.", file=sys.stderr) + return 1 + if not configure_build(root, preset, candidate.build_dir, sccache): + if sccache: + print("[INFO] sccache reconfiguration failed; retrying without it.") + sccache = None + if not configure_build(root, preset, candidate.build_dir, None): + return 1 + else: + return 1 + if not args.dry_run and not build_target(root, candidate.build_dir, target, sccache): print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) return 1 if target in {"web", "desktop"} and not args.dry_run and not refresh_web_assets(root): diff --git a/scripts/dev_tui.bat b/scripts/dev_tui.bat index ea35c616..32c84440 100644 --- a/scripts/dev_tui.bat +++ b/scripts/dev_tui.bat @@ -20,5 +20,5 @@ if not errorlevel 1 ( ) ) -"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" tui %* +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" tui --yes %* exit /b %errorlevel% diff --git a/scripts/dev_web.bat b/scripts/dev_web.bat index 202f08ab..52f1681c 100644 --- a/scripts/dev_web.bat +++ b/scripts/dev_web.bat @@ -20,5 +20,5 @@ if not errorlevel 1 ( ) ) -"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" web %* +"%PYTHON%" "%SCRIPT_DIR%dev_environment.py" web --yes %* exit /b %errorlevel% diff --git a/tests/scripts/dev_environment_test.py b/tests/scripts/dev_environment_test.py index 787cab5f..63139861 100644 --- a/tests/scripts/dev_environment_test.py +++ b/tests/scripts/dev_environment_test.py @@ -78,6 +78,71 @@ def test_noninteractive_target_selection_fails(self): with patch.object(dev_environment.sys.stdin, "isatty", return_value=False): self.assertIsNone(dev_environment.choose_target(None)) + def test_sccache_discovery_prefers_path_and_has_install_hint(self): + with patch.object(dev_environment.shutil, "which", return_value="C:/tools/sccache.exe"), \ + patch.object(Path, "is_file", return_value=True): + self.assertEqual(dev_environment.find_sccache(), Path("C:/tools/sccache.exe")) + self.assertIn("sccache", dev_environment.sccache_install_hint()) + + def test_cache_state_detects_configuration_transition(self): + with tempfile.TemporaryDirectory() as directory: + build = Path(directory) / "build" + cache = Path("C:/tools/sccache.exe") + self.assertTrue(dev_environment.cache_state_changed(build, cache)) + dev_environment.write_sccache_marker(build, cache) + self.assertFalse(dev_environment.cache_state_changed(build, cache)) + self.assertTrue(dev_environment.cache_state_changed(build, None)) + + def test_configure_and_build_disables_test_dependencies(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch.object(dev_environment.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run, \ + patch.object(dev_environment, "build_target", return_value=True): + self.assertEqual( + dev_environment.configure_and_build(root, "windows-x64-release", "web"), + root / "build/windows-x64-release", + ) + self.assertEqual( + run.call_args_list[0].args[0], + [ + "cmake", "--preset", "windows-x64-release", "-DBUILD_TESTING=OFF", + "-DCMAKE_C_COMPILER_LAUNCHER=", "-DCMAKE_CXX_COMPILER_LAUNCHER=", + ], + ) + + def test_newest_web_seed_requires_clean_same_commit_and_uses_newest_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "current" + older = Path(directory) / "older" + newer = Path(directory) / "newer" + for worktree in (root, older, newer): + (worktree / "web/dist").mkdir(parents=True) + (worktree / "web/dist/index.html").write_text("ok", encoding="utf-8") + os.utime(older / "web/dist/index.html", (1, 1)) + os.utime(newer / "web/dist/index.html", (2, 2)) + with patch.object(dev_environment, "registered_worktrees", return_value=[root, older, newer]), \ + patch.object(dev_environment, "current_commit", return_value="same"), \ + patch.object(dev_environment, "web_worktree_is_clean", return_value=True): + self.assertEqual(dev_environment.newest_web_seed(root), newer / "web/dist/index.html") + with patch.object(dev_environment, "registered_worktrees", return_value=[root, newer]), \ + patch.object(dev_environment, "current_commit", return_value="same"), \ + patch.object(dev_environment, "web_worktree_is_clean", side_effect=lambda item: item != newer): + self.assertIsNone(dev_environment.newest_web_seed(root)) + + def test_seed_web_assets_copies_current_frontend_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "current" + source = Path(directory) / "source" + (root / "web/src").mkdir(parents=True) + (root / "web/src/main.js").write_text("source", encoding="utf-8") + (source / "web/dist").mkdir(parents=True) + (source / "web/dist/index.html").write_text("output", encoding="utf-8") + freshness = iter([True, False]) + builder = type("Builder", (), {"web_build_is_stale": staticmethod(lambda _web, _index: next(freshness))}) + with patch.object(dev_environment, "newest_web_seed", return_value=source / "web/dist/index.html"): + self.assertTrue(dev_environment.seed_web_assets(root, builder)) + self.assertEqual((root / "web/dist/index.html").read_text(encoding="utf-8"), "output") + def test_main_refreshes_build_and_web_assets_before_web_start(self): candidate = dev_environment.BuildCandidate( Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") @@ -90,7 +155,7 @@ def test_main_refreshes_build_and_web_assets_before_web_start(self): patch.object(dev_environment, "refresh_web_assets", return_value=True) as refresh, \ patch.object(dev_environment, "launch_surface", return_value=0) as launch: self.assertEqual(dev_environment.main(), 0) - build.assert_called_once_with(Path("C:/work"), candidate.build_dir, "web") + build.assert_called_once_with(Path("C:/work"), candidate.build_dir, "web", None) refresh.assert_called_once_with(Path("C:/work")) launch.assert_called_once_with(Path("C:/work"), "web", candidate, False, []) @@ -107,6 +172,11 @@ def test_web_launch_forwards_the_build_and_isolated_runtime_directory(self): result = dev_environment.launch_surface(Path("C:/work"), "web", candidate, dry_run=True, extra=[]) self.assertEqual(result, 0) + def test_windows_target_launchers_auto_approve_initial_configuration(self): + for target in ("web", "desktop", "tui"): + wrapper = (ROOT / "scripts" / f"dev_{target}.bat").read_text(encoding="utf-8") + self.assertIn(f'dev_environment.py" {target} --yes %*', wrapper) + @unittest.skipUnless(os.name == "nt", "Windows batch wrapper") def test_batch_wrapper_delegates_to_python(self): with tempfile.TemporaryDirectory() as directory: From e4561af5ceca795755e5d8dd4f7fc4bf6fdfa064 Mon Sep 17 00:00:00 2001 From: Trae User Date: Sat, 19 Sep 2026 15:31:50 +0800 Subject: [PATCH 3/7] fix: make development launchers recover from cache failures --- .../add-worktree-build-acceleration/design.md | 2 +- .../specs/worktree-build-acceleration/spec.md | 4 +- .../add-worktree-build-acceleration/tasks.md | 2 +- scripts/dev_environment.py | 44 ++++++++++++++----- scripts/dev_web.py | 30 ++++++++----- tests/scripts/dev_environment_test.py | 43 +++++++++++++++++- tests/scripts/dev_web_test.py | 8 ++-- 7 files changed, 102 insertions(+), 31 deletions(-) diff --git a/openspec/changes/add-worktree-build-acceleration/design.md b/openspec/changes/add-worktree-build-acceleration/design.md index 12e93512..ed3aa829 100644 --- a/openspec/changes/add-worktree-build-acceleration/design.md +++ b/openspec/changes/add-worktree-build-acceleration/design.md @@ -33,7 +33,7 @@ Alternatives considered: ### Treat cache instrumentation as best effort -The launcher invokes `sccache --show-stats --stats-format=json` before and after building when supported, computes a delta for cache hits, misses, and errors, and prints it. Parsing or command failures disable only reporting; cache configuration failures trigger a CMake reconfiguration without launchers and continue with normal compilation. +The launcher invokes `sccache --show-stats --stats-format=json` before and after building when supported, computes a delta for cache hits, misses, and errors, and prints it. Parsing or command failures disable only reporting. Cache configuration failures or cache-enabled build failures trigger a CMake reconfiguration without launchers and one ordinary-build retry; only a failed retry stops the requested launch. ### Copy frontend artifacts, never link them diff --git a/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md b/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md index bd5db6fe..e4eb190e 100644 --- a/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md +++ b/openspec/changes/add-worktree-build-acceleration/specs/worktree-build-acceleration/spec.md @@ -18,8 +18,8 @@ The development launcher SHALL detect an available `sccache` executable using th - **THEN** it reports an optional installation suggestion and continues with normal compilation #### Scenario: Cache tool failure -- **WHEN** compiler-cache configuration, execution, or statistics collection fails -- **THEN** the launcher reports a concise reason and falls back to ordinary compilation without blocking target startup +- **WHEN** compiler-cache configuration, execution, statistics collection, or a cache-enabled build fails +- **THEN** the launcher reports a concise reason, reconfigures without compiler launchers when necessary, and retries ordinary compilation without blocking target startup ### Requirement: Cache configuration transitions The development launcher SHALL detect when an existing build directory's configured compiler-cache state differs from the currently available cache state. It SHALL reconfigure that build directory before its next incremental build to add or remove compiler launcher settings as required. diff --git a/openspec/changes/add-worktree-build-acceleration/tasks.md b/openspec/changes/add-worktree-build-acceleration/tasks.md index be67221e..b0dd7061 100644 --- a/openspec/changes/add-worktree-build-acceleration/tasks.md +++ b/openspec/changes/add-worktree-build-acceleration/tasks.md @@ -3,7 +3,7 @@ ## 1. Compiler-cache integration - [x] 1.1 Add cross-platform `sccache` discovery, installation guidance, and cache-state helpers; verify focused unit tests cover PATH, conventional paths, and absent-cache fallback. -- [x] 1.2 Configure or remove CMake compiler launchers when cache availability changes, with ordinary-build fallback on cache errors; verify subprocess command tests cover both transitions. +- [x] 1.2 Configure or remove CMake compiler launchers when cache availability changes, with ordinary-build fallback on cache configuration or cache-enabled build errors; verify subprocess command tests cover both transitions. - [x] 1.3 Collect and display best-effort per-launch `sccache` hit, miss, and error deltas; verify statistic parsing and failure fallback with focused tests. ## 2. Frontend artifact reuse diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py index 8f959f83..ca3bef63 100644 --- a/scripts/dev_environment.py +++ b/scripts/dev_environment.py @@ -126,7 +126,10 @@ def find_compatible_build(root: Path, target: str, explicit: Path | None = None) for worktree in worktrees: if current_commit(worktree) != commit: continue - directories = [explicit.resolve()] if explicit else build_directories(worktree) + directories = [explicit.resolve()] if explicit else sorted( + build_directories(worktree), + key=lambda directory: (configured_for_desktop(directory), str(directory).lower()), + ) for build_dir in directories: source_dir = cmake_source_dir(build_dir) executable = executable_for(build_dir, target) @@ -210,22 +213,29 @@ def cache_marker(build_dir: Path) -> Path: return build_dir / CACHE_MARKER -def cached_sccache_path(build_dir: Path) -> str | None: +def cached_sccache_state(build_dir: Path) -> tuple[str | None, bool] | None: try: data = json.loads(cache_marker(build_dir).read_text(encoding="utf-8")) - value = data.get("sccache") - return value if isinstance(value, str) else None + path = data.get("sccache") + enabled = data.get("enabled", True) + return (path if isinstance(path, str) else None, bool(enabled)) except (OSError, ValueError, TypeError): return None -def write_sccache_marker(build_dir: Path, sccache: Path | None) -> None: +def write_sccache_marker(build_dir: Path, sccache: Path | None, enabled: bool = True) -> None: build_dir.mkdir(parents=True, exist_ok=True) - cache_marker(build_dir).write_text(json.dumps({"sccache": str(sccache) if sccache else None}) + "\n", encoding="utf-8") + cache_marker(build_dir).write_text(json.dumps({"sccache": str(sccache) if sccache else None, "enabled": enabled}) + "\n", encoding="utf-8") def cache_state_changed(build_dir: Path, sccache: Path | None) -> bool: - return cached_sccache_path(build_dir) != (str(sccache) if sccache else None) + state = cached_sccache_state(build_dir) + return state is None or state[0] != (str(sccache) if sccache else None) + + +def sccache_disabled_for_build(build_dir: Path, sccache: Path | None) -> bool: + state = cached_sccache_state(build_dir) + return sccache is not None and state == (str(sccache), False) def configure_build(root: Path, preset: str, build_dir: Path, sccache: Path | None) -> bool: @@ -448,6 +458,10 @@ def main() -> int: else: print(f"[INFO] sccache not found. {sccache_install_hint()}") candidate = find_compatible_build(root, target, args.build_dir) + sccache_disabled = candidate is not None and sccache_disabled_for_build(candidate.build_dir, sccache) + if sccache_disabled: + print("[INFO] sccache is disabled for this build after a prior compiler failure.") + sccache = None if candidate is None: preset = default_preset(target) if preset is None: @@ -469,7 +483,7 @@ def main() -> int: if candidate is None: print("[ERROR] Build completed but did not produce a compatible executable.", file=sys.stderr) return 1 - if not args.dry_run and cache_state_changed(candidate.build_dir, sccache): + if not args.dry_run and not sccache_disabled and cache_state_changed(candidate.build_dir, sccache): preset = default_preset(target) if preset is None: print("[ERROR] Cannot reconfigure the build for this platform.", file=sys.stderr) @@ -483,8 +497,18 @@ def main() -> int: else: return 1 if not args.dry_run and not build_target(root, candidate.build_dir, target, sccache): - print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) - return 1 + if sccache: + preset = default_preset(target) + print("[INFO] sccache build failed; retrying this build without sccache.") + if preset and configure_build(root, preset, candidate.build_dir, None) and build_target(root, candidate.build_dir, target, None): + write_sccache_marker(candidate.build_dir, find_sccache(), enabled=False) + sccache = None + else: + print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) + return 1 + else: + print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) + return 1 if target in {"web", "desktop"} and not args.dry_run and not refresh_web_assets(root): print("[ERROR] Web asset refresh failed; development environment was not started.", file=sys.stderr) return 1 diff --git a/scripts/dev_web.py b/scripts/dev_web.py index 3e88e245..aaf0b8d7 100644 --- a/scripts/dev_web.py +++ b/scripts/dev_web.py @@ -113,23 +113,23 @@ def main() -> int: if args.foreground: return subprocess.run(command, cwd=project_root).returncode + runtime_dir = _runtime_dir(project_root, args.run_dir) + previous_port_mtime_ns = _port_mtime_ns(runtime_dir) run_options = {"cwd": project_root} if os.name == "nt": run_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP result = subprocess.run(command, **run_options) - # Exit 6 means the daemon validated an already-running instance. Other - # failures must not be hidden by a stale daemon.port from an earlier run. - if result.returncode not in (0, 6): - return result.returncode - - runtime_dir = _runtime_dir(project_root, args.run_dir) - port = _wait_for_port(runtime_dir) + # The Windows daemon wrapper can time out before its worker has finished + # loading configuration. Accept a nonzero wrapper exit only when it is + # followed by a freshly written port file from this launch. + allow_existing_port = result.returncode in (0, 6) + port = _wait_for_port(runtime_dir, previous_port_mtime_ns, allow_existing_port) if port is None: - print("[ERROR] Daemon started without a readable Web UI port.", file=sys.stderr) + print("[ERROR] Daemon started without a fresh readable Web UI port.", file=sys.stderr) return result.returncode or 1 - _open_web_ui(port, args.no_browser, already_running=result.returncode != 0) + _open_web_ui(port, args.no_browser, already_running=result.returncode == 6) return 0 @@ -152,13 +152,21 @@ def _open_web_ui(port: int, no_browser: bool, already_running: bool = False) -> webbrowser.open(url) -def _wait_for_port(runtime_dir: Path) -> int | None: +def _port_mtime_ns(runtime_dir: Path) -> int | None: + try: + return (runtime_dir / "daemon.port").stat().st_mtime_ns + except OSError: + return None + + +def _wait_for_port(runtime_dir: Path, previous_mtime_ns: int | None = None, allow_existing: bool = True) -> int | None: port_file = runtime_dir / "daemon.port" deadline = time.monotonic() + 30 while time.monotonic() < deadline: try: port = int(port_file.read_text(encoding="utf-8").strip()) - if 1 <= port <= 65535: + mtime_ns = port_file.stat().st_mtime_ns + if 1 <= port <= 65535 and (allow_existing or previous_mtime_ns is None or mtime_ns > previous_mtime_ns): return port except (OSError, ValueError): pass diff --git a/tests/scripts/dev_environment_test.py b/tests/scripts/dev_environment_test.py index 63139861..21127483 100644 --- a/tests/scripts/dev_environment_test.py +++ b/tests/scripts/dev_environment_test.py @@ -16,8 +16,8 @@ class DevEnvironmentTest(unittest.TestCase): - def make_build(self, root, desktop=False): - build = root / "build" / "test" + def make_build(self, root, desktop=False, name="test"): + build = root / "build" / name build.mkdir(parents=True) (build / "CMakeCache.txt").write_text( "CMAKE_HOME_DIRECTORY:INTERNAL=" + str(root) + "\n" @@ -30,6 +30,17 @@ def make_build(self, root, desktop=False): (build / dev_environment.native_executable_name("acecode-desktop")).touch() return build + def test_web_candidate_prefers_non_desktop_build(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plain = self.make_build(root, name="plain") + self.make_build(root, desktop=True, name="desktop") + with patch.object(dev_environment, "current_commit", return_value="same"), \ + patch.object(dev_environment, "registered_worktrees", return_value=[root]), \ + patch.object(dev_environment, "platform_matches", return_value=True): + candidate = dev_environment.find_compatible_build(root, "web") + self.assertEqual(candidate.build_dir, plain.resolve()) + def test_candidate_requires_matching_source_commit_and_executable(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "current" @@ -93,6 +104,14 @@ def test_cache_state_detects_configuration_transition(self): self.assertFalse(dev_environment.cache_state_changed(build, cache)) self.assertTrue(dev_environment.cache_state_changed(build, None)) + def test_sccache_build_failure_disables_only_matching_cache_path(self): + with tempfile.TemporaryDirectory() as directory: + build = Path(directory) / "build" + cache = Path("C:/tools/sccache.exe") + dev_environment.write_sccache_marker(build, cache, enabled=False) + self.assertTrue(dev_environment.sccache_disabled_for_build(build, cache)) + self.assertFalse(dev_environment.sccache_disabled_for_build(build, Path("C:/tools/sccache-new.exe"))) + def test_configure_and_build_disables_test_dependencies(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -150,7 +169,9 @@ def test_main_refreshes_build_and_web_assets_before_web_start(self): args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() with patch.object(dev_environment, "parse_args", return_value=args), \ patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ + patch.object(dev_environment, "find_sccache", return_value=None), \ patch.object(dev_environment, "find_compatible_build", return_value=candidate), \ + patch.object(dev_environment, "cache_state_changed", return_value=False), \ patch.object(dev_environment, "build_target", return_value=True) as build, \ patch.object(dev_environment, "refresh_web_assets", return_value=True) as refresh, \ patch.object(dev_environment, "launch_surface", return_value=0) as launch: @@ -164,6 +185,24 @@ def test_confirmation_eof_refuses_configuration(self): patch("builtins.input", side_effect=EOFError): self.assertFalse(dev_environment.ask_to_build("windows-x64-release", "web", False)) + def test_main_retries_failed_sccache_build_without_cache(self): + candidate = dev_environment.BuildCandidate(Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe")) + args = type("Args", (), {"target": "web", "build_dir": None, "yes": False, "dry_run": False, "extra": []})() + cache = Path("C:/tools/sccache.exe") + with patch.object(dev_environment, "parse_args", return_value=args), \ + patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ + patch.object(dev_environment, "find_sccache", return_value=cache), \ + patch.object(dev_environment, "find_compatible_build", return_value=candidate), \ + patch.object(dev_environment, "cache_state_changed", return_value=False), \ + patch.object(dev_environment, "build_target", side_effect=[False, True]) as build, \ + patch.object(dev_environment, "configure_build", return_value=True) as configure, \ + patch.object(dev_environment, "refresh_web_assets", return_value=True), \ + patch.object(dev_environment, "launch_surface", return_value=0): + self.assertEqual(dev_environment.main(), 0) + self.assertEqual(build.call_args_list[0].args, (Path("C:/work"), candidate.build_dir, "web", cache)) + self.assertEqual(build.call_args_list[1].args, (Path("C:/work"), candidate.build_dir, "web", None)) + configure.assert_called_once_with(Path("C:/work"), "windows-x64-release", candidate.build_dir, None) + def test_web_launch_forwards_the_build_and_isolated_runtime_directory(self): candidate = dev_environment.BuildCandidate( Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") diff --git a/tests/scripts/dev_web_test.py b/tests/scripts/dev_web_test.py index 5fe790da..bbbd1c86 100644 --- a/tests/scripts/dev_web_test.py +++ b/tests/scripts/dev_web_test.py @@ -41,11 +41,11 @@ def run_launcher(self, exit_code, port): result = dev_web.main() return result, wait.call_count, open_ui.call_args - def test_startup_failure_ignores_stale_port(self): + def test_worker_timeout_opens_a_fresh_port(self): result, reads, opened = self.run_launcher(3, 12345) - self.assertEqual(result, 3) - self.assertEqual(reads, 0) - self.assertIsNone(opened) + self.assertEqual(result, 0) + self.assertEqual(reads, 1) + self.assertEqual(opened.kwargs, {"already_running": False}) def test_verified_running_daemon_can_be_opened(self): result, reads, opened = self.run_launcher(6, 12345) From ee802293a25a1c5d3b12dad7cb39f4adcb2a6410 Mon Sep 17 00:00:00 2001 From: Trae User Date: Sat, 19 Sep 2026 16:11:21 +0800 Subject: [PATCH 4/7] fix: address development launcher review findings --- .../design.md | 2 +- .../proposal.md | 2 +- .../development-environment-launcher/spec.md | 6 +- scripts/dev_build_artifacts.py | 47 +++++++ scripts/dev_desktop.py | 52 +------- scripts/dev_environment.py | 126 ++++++++++-------- scripts/dev_tui.sh | 0 scripts/dev_web.py | 15 +-- scripts/dev_web.sh | 0 tests/scripts/dev_build_artifacts_test.py | 30 +++++ tests/scripts/dev_environment_test.py | 62 +++++++-- tests/scripts/dev_web_test.py | 1 + 12 files changed, 213 insertions(+), 130 deletions(-) create mode 100644 scripts/dev_build_artifacts.py mode change 100644 => 100755 scripts/dev_tui.sh mode change 100644 => 100755 scripts/dev_web.sh create mode 100644 tests/scripts/dev_build_artifacts_test.py diff --git a/openspec/changes/add-development-environment-launcher/design.md b/openspec/changes/add-development-environment-launcher/design.md index a7df8330..dab6d296 100644 --- a/openspec/changes/add-development-environment-launcher/design.md +++ b/openspec/changes/add-development-environment-launcher/design.md @@ -33,7 +33,7 @@ Alternatives considered: ### Discover builds only from Git worktree registrations -The orchestration layer will use `git worktree list --porcelain` from the current repository. It will inspect each registered worktree's candidate build directories and read `CMakeCache.txt` to confirm the configured source directory. It will compare the candidate source worktree's `HEAD` to the current worktree's `HEAD` and validate platform, generator architecture clues, selected target configuration, and executable presence. +The orchestration layer will inspect only the current worktree's candidate build directories and read `CMakeCache.txt` to confirm that each build was configured from the current source directory. It validates platform, generator architecture clues, selected target configuration, and executable presence. Registered peer worktrees are used only for safe compiler-cache and frontend-artifact acceleration; their CMake/Ninja directories are never built or launched for the current worktree. Alternatives considered: diff --git a/openspec/changes/add-development-environment-launcher/proposal.md b/openspec/changes/add-development-environment-launcher/proposal.md index 020345bd..0d895148 100644 --- a/openspec/changes/add-development-environment-launcher/proposal.md +++ b/openspec/changes/add-development-environment-launcher/proposal.md @@ -8,7 +8,7 @@ Developers currently need to choose and invoke separate Web or Desktop launchers - Add a cross-platform development-environment launcher that starts the Web daemon, Desktop shell, or terminal UI using existing repository launchers and build outputs. - Add dedicated Windows and POSIX entry points for Web, Desktop, and TUI so developers can start the desired target without providing a target argument or using an agent skill. -- Discover related worktrees through Git and validate a reusable build against the current source revision, platform, architecture, and requested target before using it. +- Validate reusable builds from the current worktree against its source directory, platform, architecture, and requested target; use related worktrees only for safe cache and frontend-artifact acceleration. - Incrementally rebuild every verified build before launch so source changes are incorporated; require confirmation only before configuring a missing or incompatible build. - Initialize the Windows Visual Studio C++ developer environment from the direct launchers so incremental builds work from a normal shell or double-clicked batch file. - Allow Windows direct launchers to configure a missing target build automatically, so double-clicked entry points do not wait for unavailable confirmation input. diff --git a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md index 9096a83b..2e56a61b 100644 --- a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md +++ b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md @@ -18,11 +18,11 @@ The development-environment launcher SHALL start exactly one selected developmen - **THEN** the skill asks whether to run Web, Desktop, or TUI before starting work ### Requirement: Compatible build reuse -Before requesting a rebuild, the launcher and skill SHALL search the Git worktrees registered for the current repository for a compatible existing build. A build is reusable only when its configured source worktree is at the current worktree's commit, its executable is runnable on the current platform and architecture, and it contains the executable required by the selected target. A Desktop target additionally requires a Desktop-enabled build and Desktop executable. The launcher and skill SHALL use a verified build directory directly and SHALL NOT copy build executables or their dependencies into the current worktree. +Before requesting a new configuration, the launcher and skill SHALL search the current worktree for a compatible existing build. A build is reusable only when its configured source directory is the current worktree, its executable is runnable on the current platform and architecture, and it contains the executable required by the selected target. A Desktop target additionally requires a Desktop-enabled build and Desktop executable. Other registered worktrees MAY provide content-addressed compiler cache entries and verified frontend artifacts, but their path-bound build directories and executables SHALL NOT be used for the current worktree. #### Scenario: Reuse a matching Web build -- **WHEN** a registered worktree has a matching commit and a compatible `acecode` executable -- **THEN** the launcher starts the Web target with that build directory +- **WHEN** the current worktree has a compatible configured `acecode` build +- **THEN** the launcher incrementally builds and starts the Web target with that build directory #### Scenario: Reject an incompatible Desktop build - **WHEN** a matching build lacks Desktop support or the Desktop executable diff --git a/scripts/dev_build_artifacts.py b/scripts/dev_build_artifacts.py new file mode 100644 index 00000000..106e332a --- /dev/null +++ b/scripts/dev_build_artifacts.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Shared development-build artifact discovery.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def candidate_build_directories(build_dir: Path, max_depth: int = 2) -> list[Path]: + if not build_dir.is_dir(): + return [] + directories = [build_dir] + frontier = [build_dir] + for _ in range(max_depth): + next_frontier: list[Path] = [] + for parent in frontier: + try: + children = sorted( + (path for path in parent.iterdir() if path.is_dir() and path.suffix != ".app"), + key=lambda path: str(path).lower(), + ) + except OSError: + continue + directories.extend(children) + next_frontier.extend(children) + frontier = next_frontier + return directories + + +def find_named_artifacts(build_dir: Path, names: list[str], app_bundle: str | None = None) -> list[Path]: + results: list[Path] = [] + seen: set[Path] = set() + for directory in candidate_build_directories(build_dir): + candidates = [directory / name for name in names] + if app_bundle: + candidates.insert(0, directory / app_bundle) + for candidate in candidates: + if not candidate.is_file() and not (app_bundle and candidate.name == app_bundle and candidate.is_dir()): + continue + if candidate.is_file() and os.name != "nt" and not os.access(candidate, os.X_OK): + continue + resolved = candidate.resolve() + if resolved not in seen: + seen.add(resolved) + results.append(candidate) + return results diff --git a/scripts/dev_desktop.py b/scripts/dev_desktop.py index cb153130..28a50ee7 100644 --- a/scripts/dev_desktop.py +++ b/scripts/dev_desktop.py @@ -26,6 +26,8 @@ import sys from pathlib import Path +from dev_build_artifacts import find_named_artifacts + def _supports_color() -> bool: if os.environ.get("NO_COLOR"): return False @@ -185,55 +187,7 @@ def build_web(web_dir: Path, pnpm: str, force: bool = False) -> None: def find_desktop_builds(build_dir: Path) -> list[Path]: """查找 build 根、直接子目录及 preset/config 两层布局的产物。""" - results: list[Path] = [] - if not build_dir.is_dir(): - return results - - search_dirs = [build_dir] - try: - first_level = sorted( - (path for path in build_dir.iterdir() - if path.is_dir() and path.suffix != ".app"), - key=lambda path: str(path).lower(), - ) - except OSError: - first_level = [] - search_dirs.extend(first_level) - for first in first_level: - try: - search_dirs.extend(sorted( - (path for path in first.iterdir() - if path.is_dir() and path.suffix != ".app"), - key=lambda path: str(path).lower(), - )) - except OSError: - continue - - seen = set() - for child in search_dirs: - # macOS .app bundle - app_bundle = child / "ACECode.app" - if app_bundle.is_dir(): - resolved = app_bundle.resolve() - if resolved not in seen: - seen.add(resolved) - results.append(app_bundle) - # Windows .exe - exe = child / "acecode-desktop.exe" - if exe.is_file(): - resolved = exe.resolve() - if resolved not in seen: - seen.add(resolved) - results.append(exe) - # Linux / macOS 裸可执行文件(非 .app) - binary = child / "acecode-desktop" - if binary.is_file() and os.access(binary, os.X_OK): - resolved = binary.resolve() - if resolved not in seen: - seen.add(resolved) - results.append(binary) - - return results + return find_named_artifacts(build_dir, ["acecode-desktop.exe", "acecode-desktop"], "ACECode.app") def display_path(path: Path, project_root: Path) -> str: diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py index ca3bef63..5d1e10a6 100644 --- a/scripts/dev_environment.py +++ b/scripts/dev_environment.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import Iterable +from dev_build_artifacts import find_named_artifacts + TARGETS = ("web", "desktop", "tui") CACHE_MARKER = ".acecode-sccache.json" @@ -37,19 +39,7 @@ def native_executable_name(name: str) -> str: def executable_for(build_dir: Path, target: str) -> Path | None: name = native_executable_name("acecode-desktop" if target == "desktop" else "acecode") - candidates = [ - build_dir / name, - build_dir / "Release" / name, - build_dir / "Debug" / name, - build_dir / "RelWithDebInfo" / name, - build_dir / "MinSizeRel" / name, - ] - for path in candidates: - if path.is_file(): - return path - if not build_dir.is_dir(): - return None - matches = sorted(path for path in build_dir.rglob(name) if path.is_file()) + matches = find_named_artifacts(build_dir, [name], "ACECode.app" if target == "desktop" else None) return matches[0] if matches else None @@ -119,25 +109,19 @@ def platform_matches(build_dir: Path) -> bool: def find_compatible_build(root: Path, target: str, explicit: Path | None = None) -> BuildCandidate | None: - commit = current_commit(root) - if not commit: - return None - worktrees = registered_worktrees(root) - for worktree in worktrees: - if current_commit(worktree) != commit: + root = root.resolve() + directories = [explicit.resolve()] if explicit else sorted( + build_directories(root), + key=lambda directory: (configured_for_desktop(directory), str(directory).lower()), + ) + for build_dir in directories: + source_dir = cmake_source_dir(build_dir) + executable = executable_for(build_dir, target) + if source_dir != root or not executable or not platform_matches(build_dir): + continue + if target == "desktop" and not configured_for_desktop(build_dir): continue - directories = [explicit.resolve()] if explicit else sorted( - build_directories(worktree), - key=lambda directory: (configured_for_desktop(directory), str(directory).lower()), - ) - for build_dir in directories: - source_dir = cmake_source_dir(build_dir) - executable = executable_for(build_dir, target) - if source_dir != worktree.resolve() or not executable or not platform_matches(build_dir): - continue - if target == "desktop" and not configured_for_desktop(build_dir): - continue - return BuildCandidate(build_dir.resolve(), source_dir, executable) + return BuildCandidate(build_dir.resolve(), source_dir, executable) return None @@ -196,8 +180,21 @@ def sccache_candidates() -> list[Path]: def find_sccache() -> Path | None: for candidate in sccache_candidates(): - if candidate.is_file(): - return candidate.resolve() + if not candidate.is_file(): + continue + resolved = candidate.resolve() + try: + result = subprocess.run( + [str(resolved), "--version"], + text=True, + capture_output=True, + check=False, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + continue + if result.returncode == 0: + return resolved return None @@ -239,7 +236,25 @@ def sccache_disabled_for_build(build_dir: Path, sccache: Path | None) -> bool: def configure_build(root: Path, preset: str, build_dir: Path, sccache: Path | None) -> bool: - command = ["cmake", "--preset", preset, "-DBUILD_TESTING=OFF"] + cache = build_dir / "CMakeCache.txt" + if cache.is_file(): + command = [ + "cmake", "-S", str(root), "-B", str(build_dir), + "-DCMAKE_BUILD_TYPE=Release", "-DBUILD_TESTING=OFF", + ] + triplet = cmake_cache_value(cache, "VCPKG_TARGET_TRIPLET") + toolchain = cmake_cache_value(cache, "CMAKE_TOOLCHAIN_FILE") + overlay = cmake_cache_value(cache, "VCPKG_OVERLAY_PORTS") + if triplet: + command.append(f"-DVCPKG_TARGET_TRIPLET={triplet}") + if toolchain: + command.append(f"-DCMAKE_TOOLCHAIN_FILE={toolchain}") + if overlay: + command.append(f"-DVCPKG_OVERLAY_PORTS={overlay}") + if configured_for_desktop(build_dir): + command.append("-DACECODE_BUILD_DESKTOP=ON") + else: + command = ["cmake", "--preset", preset, "-DBUILD_TESTING=OFF"] if sccache: command.extend([ f"-DCMAKE_C_COMPILER_LAUNCHER={sccache}", @@ -253,34 +268,35 @@ def configure_build(root: Path, preset: str, build_dir: Path, sccache: Path | No return True +def _sum_integer_leaves(value) -> int: + if isinstance(value, bool): + return 0 + if isinstance(value, int): + return value + if isinstance(value, dict): + return sum(_sum_integer_leaves(child) for child in value.values()) + if isinstance(value, list): + return sum(_sum_integer_leaves(child) for child in value) + return 0 + + def sccache_stats(sccache: Path) -> dict[str, int] | None: result = subprocess.run([str(sccache), "--show-stats", "--stats-format=json"], text=True, capture_output=True, check=False) if result.returncode != 0: return None try: data = json.loads(result.stdout) - except ValueError: + stats = data["stats"] + except (ValueError, KeyError, TypeError): return None - flat = json.dumps(data).lower() - values = {"hits": 0, "misses": 0, "errors": 0} - def collect(value): - if isinstance(value, dict): - for key, child in value.items(): - normalized = key.lower().replace("_", " ") - if isinstance(child, int): - if "hit" in normalized: - values["hits"] += child - elif "miss" in normalized: - values["misses"] += child - elif "error" in normalized: - values["errors"] += child - else: - collect(child) - elif isinstance(value, list): - for child in value: - collect(child) - collect(data) - return values if flat else None + return { + "hits": _sum_integer_leaves(stats.get("cache_hits", {})), + "misses": _sum_integer_leaves(stats.get("cache_misses", {})), + "errors": sum( + _sum_integer_leaves(stats.get(key, 0)) + for key in ("cache_errors", "cache_read_errors", "cache_write_errors", "dist_errors") + ), + } def report_sccache_delta(before: dict[str, int] | None, after: dict[str, int] | None) -> None: diff --git a/scripts/dev_tui.sh b/scripts/dev_tui.sh old mode 100644 new mode 100755 diff --git a/scripts/dev_web.py b/scripts/dev_web.py index aaf0b8d7..73c05151 100644 --- a/scripts/dev_web.py +++ b/scripts/dev_web.py @@ -11,6 +11,8 @@ import webbrowser from pathlib import Path +from dev_build_artifacts import find_named_artifacts + def find_project_root() -> Path: current = Path(__file__).resolve().parent @@ -23,18 +25,7 @@ def find_project_root() -> Path: def find_executable(build_dir: Path) -> Path | None: name = "acecode.exe" if os.name == "nt" else "acecode" - candidates = [ - build_dir / name, - build_dir / "Release" / name, - build_dir / "Debug" / name, - build_dir / "MinSizeRel" / name, - build_dir / "RelWithDebInfo" / name, - ] - for candidate in candidates: - if candidate.is_file(): - return candidate - - matches = sorted(build_dir.glob(f"**/{name}")) if build_dir.is_dir() else [] + matches = find_named_artifacts(build_dir, [name]) return matches[0] if matches else None diff --git a/scripts/dev_web.sh b/scripts/dev_web.sh old mode 100644 new mode 100755 diff --git a/tests/scripts/dev_build_artifacts_test.py b/tests/scripts/dev_build_artifacts_test.py new file mode 100644 index 00000000..a9aa4e65 --- /dev/null +++ b/tests/scripts/dev_build_artifacts_test.py @@ -0,0 +1,30 @@ +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) +spec = importlib.util.spec_from_file_location("dev_build_artifacts", ROOT / "scripts/dev_build_artifacts.py") +artifacts = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = artifacts +spec.loader.exec_module(artifacts) + + +class DevBuildArtifactsTest(unittest.TestCase): + def test_finds_root_and_two_level_artifacts_without_deep_tree_scan(self): + with tempfile.TemporaryDirectory() as directory: + build = Path(directory) + direct = build / "acecode.exe" + nested = build / "preset" / "Release" / "acecode.exe" + too_deep = build / "a" / "b" / "c" / "acecode.exe" + for path in (direct, nested, too_deep): + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + self.assertEqual(artifacts.find_named_artifacts(build, ["acecode.exe"]), [direct, nested]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/scripts/dev_environment_test.py b/tests/scripts/dev_environment_test.py index 21127483..09ac885e 100644 --- a/tests/scripts/dev_environment_test.py +++ b/tests/scripts/dev_environment_test.py @@ -1,4 +1,5 @@ import importlib.util +import json import os from pathlib import Path import sys @@ -9,6 +10,7 @@ ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) spec = importlib.util.spec_from_file_location("dev_environment", ROOT / "scripts/dev_environment.py") dev_environment = importlib.util.module_from_spec(spec) sys.modules[spec.name] = dev_environment @@ -41,19 +43,16 @@ def test_web_candidate_prefers_non_desktop_build(self): candidate = dev_environment.find_compatible_build(root, "web") self.assertEqual(candidate.build_dir, plain.resolve()) - def test_candidate_requires_matching_source_commit_and_executable(self): + def test_candidate_does_not_reuse_another_worktree_build(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "current" other = Path(directory) / "other" root.mkdir() other.mkdir() - build = self.make_build(other) - with patch.object(dev_environment, "current_commit", side_effect=lambda path: "same" if path == root or path == other else None), \ - patch.object(dev_environment, "registered_worktrees", return_value=[root, other]), \ + self.make_build(other) + with patch.object(dev_environment, "registered_worktrees", return_value=[root, other]), \ patch.object(dev_environment, "platform_matches", return_value=True): - candidate = dev_environment.find_compatible_build(root, "web") - self.assertIsNotNone(candidate) - self.assertEqual(candidate.build_dir, build.resolve()) + self.assertIsNone(dev_environment.find_compatible_build(root, "web")) def test_registered_worktrees_parses_porcelain_prefix(self): root = Path("C:/work") @@ -89,12 +88,21 @@ def test_noninteractive_target_selection_fails(self): with patch.object(dev_environment.sys.stdin, "isatty", return_value=False): self.assertIsNone(dev_environment.choose_target(None)) - def test_sccache_discovery_prefers_path_and_has_install_hint(self): + def test_sccache_discovery_prefers_usable_path_and_has_install_hint(self): + completed = subprocess.CompletedProcess([], 0, stdout="sccache 1.0") with patch.object(dev_environment.shutil, "which", return_value="C:/tools/sccache.exe"), \ - patch.object(Path, "is_file", return_value=True): + patch.object(Path, "is_file", return_value=True), \ + patch.object(dev_environment.subprocess, "run", return_value=completed): self.assertEqual(dev_environment.find_sccache(), Path("C:/tools/sccache.exe")) self.assertIn("sccache", dev_environment.sccache_install_hint()) + def test_sccache_discovery_rejects_unusable_file(self): + completed = subprocess.CompletedProcess([], 1, stdout="") + with patch.object(dev_environment, "sccache_candidates", return_value=[Path("C:/broken/sccache.exe")]), \ + patch.object(Path, "is_file", return_value=True), \ + patch.object(dev_environment.subprocess, "run", return_value=completed): + self.assertIsNone(dev_environment.find_sccache()) + def test_cache_state_detects_configuration_transition(self): with tempfile.TemporaryDirectory() as directory: build = Path(directory) / "build" @@ -129,6 +137,23 @@ def test_configure_and_build_disables_test_dependencies(self): ], ) + def test_existing_build_reconfiguration_targets_candidate_directory(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "source" + build = self.make_build(root) + cache = build / "CMakeCache.txt" + cache.write_text( + cache.read_text(encoding="utf-8") + + "CMAKE_TOOLCHAIN_FILE:FILEPATH=C:/vcpkg/toolchain.cmake\n" + + "VCPKG_OVERLAY_PORTS:STRING=C:/work/ports\n", + encoding="utf-8", + ) + with patch.object(dev_environment.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertTrue(dev_environment.configure_build(root, "windows-x64-release", build, None)) + command = run.call_args.args[0] + self.assertEqual(command[:5], ["cmake", "-S", str(root), "-B", str(build)]) + self.assertIn("-DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/toolchain.cmake", command) + def test_newest_web_seed_requires_clean_same_commit_and_uses_newest_output(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "current" @@ -162,6 +187,24 @@ def test_seed_web_assets_copies_current_frontend_output(self): self.assertTrue(dev_environment.seed_web_assets(root, builder)) self.assertEqual((root / "web/dist/index.html").read_text(encoding="utf-8"), "output") + def test_sccache_stats_parses_real_nested_shape(self): + payload = { + "stats": { + "cache_hits": {"counts": {"C/C++": 7}, "adv_counts": {"C/C++": 2}}, + "cache_misses": {"counts": {"C/C++": 3}, "adv_counts": {}}, + "cache_errors": {"counts": {"C/C++": 1}, "adv_counts": {}}, + "cache_read_errors": 2, + "cache_write_errors": 1, + "dist_errors": 4, + } + } + completed = subprocess.CompletedProcess([], 0, stdout=json.dumps(payload)) + with patch.object(dev_environment.subprocess, "run", return_value=completed): + self.assertEqual( + dev_environment.sccache_stats(Path("sccache")), + {"hits": 9, "misses": 3, "errors": 8}, + ) + def test_main_refreshes_build_and_web_assets_before_web_start(self): candidate = dev_environment.BuildCandidate( Path("C:/work/build"), Path("C:/work"), Path("C:/work/build/acecode.exe") @@ -192,6 +235,7 @@ def test_main_retries_failed_sccache_build_without_cache(self): with patch.object(dev_environment, "parse_args", return_value=args), \ patch.object(dev_environment, "project_root", return_value=Path("C:/work")), \ patch.object(dev_environment, "find_sccache", return_value=cache), \ + patch.object(dev_environment, "sccache_disabled_for_build", return_value=False), \ patch.object(dev_environment, "find_compatible_build", return_value=candidate), \ patch.object(dev_environment, "cache_state_changed", return_value=False), \ patch.object(dev_environment, "build_target", side_effect=[False, True]) as build, \ diff --git a/tests/scripts/dev_web_test.py b/tests/scripts/dev_web_test.py index bbbd1c86..f3709629 100644 --- a/tests/scripts/dev_web_test.py +++ b/tests/scripts/dev_web_test.py @@ -9,6 +9,7 @@ ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) spec = importlib.util.spec_from_file_location("dev_web", ROOT / "scripts/dev_web.py") dev_web = importlib.util.module_from_spec(spec) spec.loader.exec_module(dev_web) From 542fd2e066893e98b3d653c4675fd35c41eb9d7e Mon Sep 17 00:00:00 2001 From: Trae User Date: Sat, 19 Sep 2026 17:18:57 +0800 Subject: [PATCH 5/7] fix: load desktop launcher helper in direct tests --- scripts/dev_desktop.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/dev_desktop.py b/scripts/dev_desktop.py index 28a50ee7..20005075 100644 --- a/scripts/dev_desktop.py +++ b/scripts/dev_desktop.py @@ -26,6 +26,10 @@ import sys from pathlib import Path +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + from dev_build_artifacts import find_named_artifacts def _supports_color() -> bool: From 3fd5f8bd14098b970095db3eb737a93b0dfe2bca Mon Sep 17 00:00:00 2001 From: Trae User Date: Sat, 19 Sep 2026 17:55:40 +0800 Subject: [PATCH 6/7] fix: discover desktop artifacts across platforms --- scripts/dev_build_artifacts.py | 14 ++++++++++++-- scripts/dev_desktop.py | 7 ++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/dev_build_artifacts.py b/scripts/dev_build_artifacts.py index 106e332a..65bc45af 100644 --- a/scripts/dev_build_artifacts.py +++ b/scripts/dev_build_artifacts.py @@ -28,7 +28,12 @@ def candidate_build_directories(build_dir: Path, max_depth: int = 2) -> list[Pat return directories -def find_named_artifacts(build_dir: Path, names: list[str], app_bundle: str | None = None) -> list[Path]: +def find_named_artifacts( + build_dir: Path, + names: list[str], + app_bundle: str | None = None, + require_executable: bool = True, +) -> list[Path]: results: list[Path] = [] seen: set[Path] = set() for directory in candidate_build_directories(build_dir): @@ -38,7 +43,12 @@ def find_named_artifacts(build_dir: Path, names: list[str], app_bundle: str | No for candidate in candidates: if not candidate.is_file() and not (app_bundle and candidate.name == app_bundle and candidate.is_dir()): continue - if candidate.is_file() and os.name != "nt" and not os.access(candidate, os.X_OK): + if ( + require_executable + and candidate.is_file() + and os.name != "nt" + and not os.access(candidate, os.X_OK) + ): continue resolved = candidate.resolve() if resolved not in seen: diff --git a/scripts/dev_desktop.py b/scripts/dev_desktop.py index 20005075..9eb29fcc 100644 --- a/scripts/dev_desktop.py +++ b/scripts/dev_desktop.py @@ -191,7 +191,12 @@ def build_web(web_dir: Path, pnpm: str, force: bool = False) -> None: def find_desktop_builds(build_dir: Path) -> list[Path]: """查找 build 根、直接子目录及 preset/config 两层布局的产物。""" - return find_named_artifacts(build_dir, ["acecode-desktop.exe", "acecode-desktop"], "ACECode.app") + return find_named_artifacts( + build_dir, + ["acecode-desktop.exe", "acecode-desktop"], + "ACECode.app", + require_executable=False, + ) def display_path(path: Path, project_root: Path) -> str: From 566b7695893169c2e4751e5012868f202a704329 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sun, 20 Sep 2026 04:36:14 +0800 Subject: [PATCH 7/7] fix: preserve development launcher targets and arguments --- .../skills/development-environment/SKILL.md | 6 +- CMakePresets.json | 16 ++ .../design.md | 10 +- .../development-environment-launcher/spec.md | 33 ++- .../tasks.md | 10 + scripts/dev_desktop.bat | 2 - scripts/dev_environment.py | 203 +++++++++++--- scripts/dev_tui.bat | 2 - scripts/dev_web.bat | 2 - scripts/dev_web.py | 3 +- scripts/dev_windows_env.bat | 14 +- tests/scripts/dev_build_artifacts_test.py | 1 + tests/scripts/dev_environment_review_test.py | 255 ++++++++++++++++++ tests/scripts/dev_environment_test.py | 22 +- tests/scripts/dev_web_test.py | 2 + 15 files changed, 524 insertions(+), 57 deletions(-) create mode 100644 tests/scripts/dev_environment_review_test.py diff --git a/.agents/skills/development-environment/SKILL.md b/.agents/skills/development-environment/SKILL.md index d47b189c..5a10e0e0 100644 --- a/.agents/skills/development-environment/SKILL.md +++ b/.agents/skills/development-environment/SKILL.md @@ -1,6 +1,6 @@ --- name: development-environment -description: Start ACECode's Web, Desktop, or TUI development environment safely, reusing a compatible build from a registered Git worktree when possible. +description: Start ACECode's Web, Desktop, or TUI development environment safely, reusing a compatible build from the current Git worktree when possible. --- # Development Environment @@ -39,7 +39,7 @@ Pass `--build-dir ` only when the user explicitly supplies a candidate bui ## Build reuse and rebuild policy -The launcher discovers worktrees with `git worktree list --porcelain`, reads candidate `CMakeCache.txt` files, and validates the configured source path, current commit, platform, architecture clues, required executable, and Desktop configuration before reuse. +启动器只复用当前工作树内的 CMake 构建,检查源码路径、平台、架构、目标产物及 Desktop 配置。多配置构建会编译并启动同一配置。其他已登记工作树仅可提供经验证的前端产物和编译缓存,不提供本工作树实际运行的程序。 Every launch incrementally builds the verified target, so source changes are incorporated even when the configured build is reused. Web and Desktop also refresh frontend assets when their inputs are newer than `web/dist`. @@ -51,6 +51,8 @@ If no compatible configured build exists, the launcher reports the platform CMak The shared launcher calls the existing Python surface launchers: `scripts/dev_web.py` for Web and `scripts/dev_desktop.py` for Desktop. Web uses a worktree-isolated runtime directory and opens its resulting local URL; Desktop opens its application window; TUI opens a new terminal window. +Windows 的 MSVC 构建会按 x64 或 ARM64 初始化 VS 环境;有效 MinGW 构建不要求 VS。Web 重建前发现既存 PID 记录时会明确失败并给出检查或停止命令;不要通过删 PID 文件、宽泛终止进程等方式绕过此检查。显式 `--run-dir` 只检查所指定的目录。 + ## Report outcome After a successful command, report the selected target and whether the build was reused or compiled. For Web, include the URL printed by the launcher. If startup fails, provide the launcher error and do not claim the environment is running. diff --git a/CMakePresets.json b/CMakePresets.json index c7504838..22bc39f4 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -64,6 +64,22 @@ "VCPKG_TARGET_TRIPLET": "x64-windows-static" } }, + { + "name": "windows-arm64-release", + "displayName": "Windows ARM64 Release", + "inherits": "release-base", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "arm64-windows-static" + } + }, + { + "name": "windows-arm64-desktop-release", + "displayName": "Windows ARM64 Desktop Release", + "inherits": "desktop-release-base", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "arm64-windows-static" + } + }, { "name": "windows-x64-winlibs-debug", "displayName": "Windows x64 WinLibs Debug", diff --git a/openspec/changes/add-development-environment-launcher/design.md b/openspec/changes/add-development-environment-launcher/design.md index dab6d296..056d78fc 100644 --- a/openspec/changes/add-development-environment-launcher/design.md +++ b/openspec/changes/add-development-environment-launcher/design.md @@ -55,7 +55,7 @@ Double-clicked batch files do not provide a reliable input stream for the shared ### Initialize the Windows C++ toolchain in batch entry points -The three Windows target wrappers will call a shared batch helper before invoking Python. The helper locates `vswhere.exe` from the Visual Studio installer location, asks it for an installation containing the x64 C++ tools component, and calls that installation's `VsDevCmd.bat` with `-arch=amd64 -host_arch=amd64`. It preserves the caller's command context while supplying the standard-library and linker paths that CMake needs. If discovery fails, it reports the Build Tools C++ workload requirement and exits before Python runs. +Windows 包装脚本先选择 Python,公共启动器识别候选构建后才决定是否需要 MSVC。MinGW 构建直接使用已有编译器;MSVC 路径通过共享 `dev_windows_env.bat` 查询匹配 x64 或 ARM64 的 VS 组件并初始化环境。Python 只在子进程内捕获环境变量,再传递给后续编译,不输出环境内容。帮助、列表和 dry-run 不触发工具链初始化。Windows ARM64 对应的两个默认 configure presets 与 x64 使用相同的基础配置。 Alternatives considered: @@ -73,6 +73,14 @@ Alternatives considered: ## Risks / Trade-offs +### 发布审查收敛 + +- 多配置构建记录实际产物的配置名称,并通过 `cmake --build --config` 编译同一配置;仅修改编译缓存 launcher 时保留已有 `CMAKE_BUILD_TYPE` 和 `BUILD_TESTING`。独立的嵌套 preset 不属于父级 CMake 构建。 +- 公共启动器向 Desktop 传递已验证的具体产物,向 Web 传递该可执行文件所在目录,避免二次发现选到其他配置。`--list` 只列举产物;`--rebuild` 强制刷新前端。 +- Windows TUI 直接创建新控制台进程;macOS 用 Terminal 的 AppleScript 入口执行经过逐参数 shell 引用的 `cd` 和 `exec`,确保工作目录与参数一致。 +- Web 重建前遇到既存 PID 记录时,复用原有 `daemon status` 身份校验并明确失败。此次不引入跨平台进程管理框架,也不调用现有仅按 PID 终止的 `daemon stop`。仅在核验成功后显示停止命令供开发者操作;无法确认身份时只显示检查命令。默认目录检查包含当前工作树旧提交的 runtime,显式目录只检查自身。 +- 回归使用临时目录与替身命令;Windows 另验证真实 VS 环境初始化。macOS/Linux 图形终端和 ARM64 原生编译不在本轮 Windows 主机验证范围内。 + - [A valid external build is configured with an unusual directory layout] → inspect standard build directories plus explicit `--build-dir`; require an exact `CMakeCache.txt` source match. - [CMake does not expose a fully portable architecture field] → require a runnable platform-native executable and compare configured generator/platform fields when present; reject uncertain configurations. - [A worktree-specific runtime path is untracked] → create it under the repository's ignored `.acecode` development state and document it in the launcher output. diff --git a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md index 2e56a61b..ad6e820d 100644 --- a/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md +++ b/openspec/changes/add-development-environment-launcher/specs/development-environment-launcher/spec.md @@ -28,6 +28,14 @@ Before requesting a new configuration, the launcher and skill SHALL search the c - **WHEN** a matching build lacks Desktop support or the Desktop executable - **THEN** the launcher does not use it for the Desktop target +#### Scenario: 多配置构建保持产物一致 +- **WHEN** 同一 CMake 构建包含 Debug、Release 等多个配置 +- **THEN** 增量编译 SHALL 明确指定所选产物对应的配置,Web 与 Desktop SHALL 启动该产物,不能再次选择另一配置的旧程序 + +#### Scenario: 排除嵌套的独立构建 +- **WHEN** 某个候选目录的子目录存在独立 CMakeCache.txt +- **THEN** 该子目录的产物 SHALL 按子目录自己的配置与源码路径验证,不能归入父级构建 + ### Requirement: Fresh incremental builds and configuration confirmation Before starting a selected target from a compatible build directory, the launcher SHALL run that target's incremental CMake build so changed source files are incorporated. Before starting Web or Desktop, it SHALL also ensure the development frontend assets are current. When no compatible configured build exists, the launcher and skill SHALL report the missing requirement and the CMake preset selected for the current platform, then configure the development target with testing disabled so optional unit-test dependencies do not block startup. They SHALL obtain explicit developer confirmation before configuring a new build unless a Windows direct entry point supplies its automatic approval. A declined confirmation SHALL leave source and build files unchanged and SHALL not start a development target. @@ -51,15 +59,19 @@ Windows target-specific batch entry points SHALL pass automatic configuration ap - **THEN** it configures, builds, and starts the Web target without waiting for confirmation input ### Requirement: Windows compiler environment initialization -Before a Windows target-specific entry point runs an incremental CMake build, it SHALL initialize an installed Visual Studio C++ developer environment for the host and target architecture. If no suitable Visual Studio C++ tools installation is available, it SHALL exit before invoking the shared launcher and report how to install the required Build Tools workload. +Windows 启动器 SHALL 在需要 MSVC 编译时初始化与本机及目标架构匹配的 Visual Studio C++ 环境;x64 与 ARM64 默认配置预设 SHALL 实际存在。已验证的 MinGW 构建 SHALL 可以使用现有工具链,不要求安装 Visual Studio。缺少所需 MSVC 工具时 SHALL 在开始编译前明确报错,并说明 Build Tools C++ 工作负载要求。帮助、产物列表和 dry-run SHALL 不要求初始化编译环境。 #### Scenario: Start from a normal Windows shell - **WHEN** a developer starts a Windows target-specific entry point from a shell without Visual Studio compiler variables - **THEN** the entry point initializes the developer environment and the incremental build receives the C++ standard-library include paths #### Scenario: Missing Visual Studio C++ tools -- **WHEN** a Windows target-specific entry point cannot locate a Visual Studio C++ developer command script -- **THEN** it reports the missing Build Tools prerequisite and does not start a build +- **WHEN** Windows 启动器需要 MSVC,但找不到适配架构的 Visual Studio C++ 开发环境 +- **THEN** 启动器报告所需 Build Tools 组件,并且不开始编译 + +#### Scenario: 纯 MinGW 环境 +- **WHEN** 开发者提供当前工作树内有效的 x64-mingw-static 构建,且未安装 Visual Studio +- **THEN** 启动器使用该构建的 MinGW 工具链继续增量编译 ### Requirement: Target-specific startup The launcher SHALL reuse the repository's existing Web and Desktop launch scripts for those targets. It SHALL start TUI in a new terminal window. Web startup SHALL use a runtime directory isolated to the current worktree and SHALL open the resulting local Web URL after successful startup. Desktop startup SHALL open the Desktop application after successful startup. @@ -72,6 +84,21 @@ The launcher SHALL reuse the repository's existing Web and Desktop launch script - **WHEN** a developer starts the TUI target - **THEN** the launcher opens the TUI executable in a new terminal window +#### Scenario: TUI 保留工作目录和参数 +- **WHEN** 工作树路径或 TUI 参数包含空格、引号及 shell 特殊字符 +- **THEN** 新终端 SHALL 在当前工作树目录执行 TUI,并将每个参数按原值传递;Windows SHALL 不使用 cmd/start 重新解释这些参数 + +### Requirement: Web 开发实例占用检查 +Web 启动器 SHALL 在增量编译前检查所选 runtime 是否存在 daemon PID 记录。对于默认 runtime,检查 SHALL 包括当前工作树其他提交遗留的启动器目录。存在记录时 SHALL 使用现有 daemon status 机制有界核验并明确失败,避免编译后继续复用旧 worker 或覆盖正在运行的 Windows 可执行文件。核验成功时 SHALL 输出该实例的精确停止命令,核验失败时 SHALL 仅输出检查建议;启动器 SHALL NOT 自动终止进程或删除 runtime 文件。显式指定 runtime 时 SHALL 仅检查该目录。 + +#### Scenario: 已存在开发 daemon +- **WHEN** 所选或本工作树旧的默认 runtime 仍有 PID 记录 +- **THEN** 启动器在编译前退出,报告该目录和检查结果,不编译、不启动、不终止任何进程 + +#### Scenario: 显式指定 runtime +- **WHEN** 开发者传入 --run-dir +- **THEN** 启动器只检查该目录,不检查、终止或清理默认目录内的 daemon + ### Requirement: Target-specific cross-platform entry points The repository SHALL provide dedicated thin Windows and POSIX entry points for Web, Desktop, and TUI. Each entry point SHALL select its target without requiring a target argument, delegate to the shared development-environment launcher, pass supported command-line arguments through unchanged, and provide a clear error when no supported Python interpreter is available. diff --git a/openspec/changes/add-development-environment-launcher/tasks.md b/openspec/changes/add-development-environment-launcher/tasks.md index 27b6abb0..f9a15467 100644 --- a/openspec/changes/add-development-environment-launcher/tasks.md +++ b/openspec/changes/add-development-environment-launcher/tasks.md @@ -17,3 +17,13 @@ - [x] 3.1 Add a shared Windows Visual Studio developer-environment helper and invoke it from each target-specific batch entry point; verify a normal shell receives C++ compiler include paths. - [x] 3.2 Make Windows target-specific entry points automatically approve a missing-build configuration, while preserving explicit confirmation for Python and POSIX callers; verify wrapper argument forwarding and focused launcher tests. - [x] 3.3 Run Python compilation checks and `openspec validate add-development-environment-launcher --strict`. + +## 4. 发布前审查修复 + +- [x] 4.1 保留现有构建类型,并让多配置构建、产物选择与实际启动使用同一配置;补充 Debug/Release 并存回归。 +- [x] 4.2 完整传递 TUI 参数,Windows 直接创建新控制台以避免 shell 转义错误,macOS 显式设置工作目录;验证特殊字符参数与启动错误传播。 +- [x] 4.3 正确识别 Windows MinGW 构建,按本机架构初始化 MSVC,仅需要 MSVC 时要求安装 Visual Studio。 +- [x] 4.4 Web 重编译前有界核验既存 runtime 并明确失败;提供身份已验证实例的停止命令,身份不明时仅提示检查,不自动终止进程或删除状态文件。 +- [x] 4.5 运行隔离 Python 回归、语法检查及 OpenSpec 严格验证,记录三平台实际验证边界。 + +验证记录(2026-09-20):54 项 `dev_*test.py` 测试通过;9 个 Python 文件通过 Python 3.8 语法解析;3 个 POSIX 包装脚本通过 Bash 语法检查;两个 OpenSpec change 严格验证通过。Windows 实际验证 MSVC x64 环境初始化与包含空格、`&`、`%PATH%`、`!` 的辅助脚本路径。macOS/Linux 验证命令构造及参数保留,未启动图形界面;未运行 ARM64 原生构建。 diff --git a/scripts/dev_desktop.bat b/scripts/dev_desktop.bat index 7fa1fc73..b608e276 100644 --- a/scripts/dev_desktop.bat +++ b/scripts/dev_desktop.bat @@ -4,8 +4,6 @@ REM Usage: scripts\dev_desktop.bat [Desktop launcher options] setlocal set "SCRIPT_DIR=%~dp0" -call "%SCRIPT_DIR%dev_windows_env.bat" -if errorlevel 1 exit /b %errorlevel% where python >nul 2>&1 if not errorlevel 1 ( diff --git a/scripts/dev_environment.py b/scripts/dev_environment.py index 5d1e10a6..0aafceb5 100644 --- a/scripts/dev_environment.py +++ b/scripts/dev_environment.py @@ -9,6 +9,7 @@ import os import platform import re +import shlex import shutil import subprocess import sys @@ -27,6 +28,7 @@ class BuildCandidate: build_dir: Path source_dir: Path executable: Path + configuration: str | None = None def project_root() -> Path: @@ -39,8 +41,29 @@ def native_executable_name(name: str) -> str: def executable_for(build_dir: Path, target: str) -> Path | None: name = native_executable_name("acecode-desktop" if target == "desktop" else "acecode") - matches = find_named_artifacts(build_dir, [name], "ACECode.app" if target == "desktop" else None) - return matches[0] if matches else None + app_bundle = "ACECode.app" if target == "desktop" and sys.platform == "darwin" else None + matches = find_named_artifacts(build_dir, [name], app_bundle) + for executable in matches: + # A nested preset is a different CMake build, even when its binaries + # happen to be below this build's directory. Validate it separately. + directory = executable.parent + while directory != build_dir and not (directory / "CMakeCache.txt").is_file(): + directory = directory.parent + if directory == build_dir: + return executable + return None + + +def artifact_configuration(build_dir: Path, executable: Path) -> str | None: + configurations = cmake_cache_value(build_dir / "CMakeCache.txt", "CMAKE_CONFIGURATION_TYPES") + if not configurations: + return None + relative_parts = executable.relative_to(build_dir).parts[:-1] + for configuration in configurations.split(";"): + if configuration in relative_parts: + return configuration + # Shared output directories still require an explicit build configuration. + return "Release" if "Release" in configurations.split(";") else configurations.split(";")[0] def cmake_cache_value(cache: Path, key: str) -> str | None: @@ -101,16 +124,16 @@ def platform_matches(build_dir: Path) -> bool: if not triplet: return True system = platform.system().lower() - expected_system = "windows" if system == "windows" else "osx" if system == "darwin" else "linux" - machine = platform.machine().lower() + expected_systems = ("windows", "mingw") if system == "windows" else ("osx",) if system == "darwin" else ("linux",) + machine = native_machine() expected_arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"amd64", "x86_64"} else None triplet_lower = triplet.lower() - return expected_system in triplet_lower and (expected_arch is None or expected_arch in triplet_lower) + return any(expected in triplet_lower for expected in expected_systems) and (expected_arch is None or expected_arch in triplet_lower) def find_compatible_build(root: Path, target: str, explicit: Path | None = None) -> BuildCandidate | None: root = root.resolve() - directories = [explicit.resolve()] if explicit else sorted( + directories = [(root / explicit).resolve()] if explicit else sorted( build_directories(root), key=lambda directory: (configured_for_desktop(directory), str(directory).lower()), ) @@ -121,13 +144,13 @@ def find_compatible_build(root: Path, target: str, explicit: Path | None = None) continue if target == "desktop" and not configured_for_desktop(build_dir): continue - return BuildCandidate(build_dir.resolve(), source_dir, executable) + return BuildCandidate(build_dir.resolve(), source_dir, executable, artifact_configuration(build_dir, executable)) return None def default_preset(target: str) -> str | None: system = platform.system().lower() - machine = platform.machine().lower() + machine = native_machine() arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"amd64", "x86_64"} else None if not arch: return None @@ -138,6 +161,48 @@ def default_preset(target: str) -> str | None: return f"{prefix}-{arch}{suffix}" +def native_machine() -> str: + if platform.system().lower() == "windows": + architecture = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE") + if architecture: + return architecture.lower() + return platform.machine().lower() + + +def needs_msvc(candidate: BuildCandidate | None) -> bool: + if candidate is None: + return True # The Windows presets use MSVC. + cache = candidate.build_dir / "CMakeCache.txt" + triplet = (cmake_cache_value(cache, "VCPKG_TARGET_TRIPLET") or "").lower() + compiler = (cmake_cache_value(cache, "CMAKE_CXX_COMPILER") or "").lower() + return "mingw" not in triplet and not compiler.endswith(("g++.exe", "g++")) + + +def ensure_windows_environment(root: Path, candidate: BuildCandidate | None) -> bool: + if os.name != "nt" or not needs_msvc(candidate): + return True + environment = os.environ.copy() + environment["ACECODE_DEV_ENV_SCRIPT"] = str(root / "scripts/dev_windows_env.bat") + try: + # Expanding the path once, inside quotes, preserves spaces and cmd + # metacharacters in the checkout path. Never log the environment dump. + result = subprocess.run('"%ACECODE_DEV_ENV_SCRIPT%" --print-env', shell=True, + env=environment, capture_output=True, text=True, + errors="replace", timeout=60, check=False, + creationflags=subprocess.CREATE_NO_WINDOW) + except (OSError, subprocess.TimeoutExpired) as error: + print(f"[ERROR] Could not initialize the Visual Studio environment: {error}", file=sys.stderr) + return False + if result.returncode != 0: + print(result.stdout.strip() or result.stderr.strip() or "[ERROR] Visual Studio C++ initialization failed.", file=sys.stderr) + return False + for line in result.stdout.splitlines(): + key, separator, value = line.partition("=") + if separator and key and not key.startswith("="): + os.environ[key] = value + return True + + def ask_to_build(preset: str, target: str, assume_yes: bool) -> bool: binary_dir = f"build/{preset}" executable_target = "acecode-desktop" if target == "desktop" else "acecode" @@ -240,7 +305,6 @@ def configure_build(root: Path, preset: str, build_dir: Path, sccache: Path | No if cache.is_file(): command = [ "cmake", "-S", str(root), "-B", str(build_dir), - "-DCMAKE_BUILD_TYPE=Release", "-DBUILD_TESTING=OFF", ] triplet = cmake_cache_value(cache, "VCPKG_TARGET_TRIPLET") toolchain = cmake_cache_value(cache, "CMAKE_TOOLCHAIN_FILE") @@ -307,11 +371,14 @@ def report_sccache_delta(before: dict[str, int] | None, after: dict[str, int] | print(f"[INFO] sccache this build: hits={delta['hits']} misses={delta['misses']} errors={delta['errors']}") -def build_target(root: Path, build_dir: Path, target: str, sccache: Path | None = None) -> bool: +def build_target(root: Path, build_dir: Path, target: str, sccache: Path | None = None, configuration: str | None = None) -> bool: executable_target = "acecode-desktop" if target == "desktop" else "acecode" before = sccache_stats(sccache) if sccache else None + command = ["cmake", "--build", str(build_dir), "--target", executable_target] + if configuration: + command.extend(("--config", configuration)) result = subprocess.run( - ["cmake", "--build", str(build_dir), "--target", executable_target], + command, cwd=root, check=False, ) @@ -380,16 +447,16 @@ def seed_web_assets(root: Path, builder) -> bool: return False -def refresh_web_assets(root: Path) -> bool: +def refresh_web_assets(root: Path, force: bool = False) -> bool: module = load_web_builder(root) if module is None: print("[ERROR] Cannot load Web asset builder.", file=sys.stderr) return False - if seed_web_assets(root, module): + if not force and seed_web_assets(root, module): return True try: _, pnpm = module.ensure_node_and_pnpm() - module.build_web(root / "web", pnpm) + module.build_web(root / "web", pnpm, force=force) except (OSError, subprocess.SubprocessError, SystemExit): return False return True @@ -401,17 +468,59 @@ def worktree_runtime_dir(root: Path) -> Path: return root / ".acecode" / "dev-run" / safe +def selected_web_runtime_dir(root: Path, extra: list[str]) -> Path: + parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False) + parser.add_argument("--run-dir", type=Path) + args, _ = parser.parse_known_args(extra) + if args.run_dir is None: + return worktree_runtime_dir(root) + return args.run_dir.resolve() if args.run_dir.is_absolute() else (root / args.run_dir).resolve() + + +def web_runtime_is_available(root: Path, candidate: BuildCandidate | None, extra: list[str]) -> bool: + run_dir = selected_web_runtime_dir(root, extra) + explicit = any(arg == "--run-dir" or arg.startswith("--run-dir=") for arg in extra) + if not explicit and run_dir.parent.is_dir(): + # A new commit changes the default run-dir name but an older worker + # can still hold this build's executable open. Inspect only this + # worktree's own launcher directories, without stopping any process. + prefix = re.sub(r"[^A-Za-z0-9_.-]", "-", root.name + "-") + for previous in sorted(run_dir.parent.iterdir()): + if previous.name.startswith(prefix) and (previous / "daemon.pid").exists(): + run_dir = previous + break + if not (run_dir / "daemon.pid").exists(): + return True + print(f"[ERROR] Existing Web daemon runtime must be stopped before rebuilding: {run_dir}", file=sys.stderr) + if candidate is None: + print("[ERROR] No verified executable is available to inspect that runtime; check it manually.", file=sys.stderr) + return False + command = [str(candidate.executable), "daemon", "status", f"--run-dir={run_dir}"] + try: + result = subprocess.run(command, cwd=root, text=True, capture_output=True, timeout=10, check=False) + if result.returncode == 0: + command = [command[0], "daemon", "stop", command[3]] + print("[INFO] Stop this verified daemon, then rerun the launcher:", file=sys.stderr) + else: + print("[ERROR] Runtime identity is unverified; inspect it before stopping or removing anything:", file=sys.stderr) + except (OSError, subprocess.TimeoutExpired): + print("[ERROR] Runtime identity check failed; inspect it manually:", file=sys.stderr) + formatted = "& " + " ".join("'" + part.replace("'", "''") + "'" for part in command) if os.name == "nt" else shlex.join(command) + print(("PowerShell: " if os.name == "nt" else "") + formatted, file=sys.stderr) + return False + + def launch_surface(root: Path, target: str, candidate: BuildCandidate, dry_run: bool, extra: list[str]) -> int: if target == "web": - run_dir = worktree_runtime_dir(root) - command = [sys.executable, str(root / "scripts" / "dev_web.py"), "--build-dir", str(candidate.build_dir), "--run-dir", str(run_dir), *extra] + run_dir = selected_web_runtime_dir(root, extra) + command = [sys.executable, str(root / "scripts" / "dev_web.py"), "--build-dir", str(candidate.executable.parent), "--run-dir", str(run_dir), *extra] print(f"[INFO] Launching Web with build: {candidate.build_dir}") print(f"[INFO] Isolated runtime directory: {run_dir}") elif target == "desktop": - command = [sys.executable, str(root / "scripts" / "dev_desktop.py"), "--build-dir", str(candidate.build_dir), "--no-build", *extra] + command = [sys.executable, str(root / "scripts" / "dev_desktop.py"), "--build-dir", str(candidate.executable), "--no-build", *extra] print(f"[INFO] Launching Desktop with build: {candidate.build_dir}") else: - command = tui_command(root, candidate.executable) + command = tui_command(root, candidate.executable, extra) if command is None: print(f"[ERROR] Cannot open a new terminal automatically. Run: {candidate.executable}", file=sys.stderr) return 1 @@ -419,27 +528,40 @@ def launch_surface(root: Path, target: str, candidate: BuildCandidate, dry_run: if dry_run: print("[INFO] Dry run: " + " ".join(f'"{part}"' if " " in part else part for part in command)) return 0 - return subprocess.Popen(command, cwd=root).wait() if target == "web" else (subprocess.Popen(command, cwd=root) and 0) + try: + if target == "tui" and os.name == "nt": + subprocess.Popen(command, cwd=root, creationflags=subprocess.CREATE_NEW_CONSOLE) + return 0 + if target == "tui" and sys.platform != "darwin": + subprocess.Popen(command, cwd=root, start_new_session=True) + return 0 + # Surface scripts and osascript finish after creating the UI; waiting + # propagates launcher/argument failures without waiting for the GUI. + return subprocess.run(command, cwd=root, check=False).returncode + except OSError as error: + print(f"[ERROR] Could not start {target}: {error}", file=sys.stderr) + return 1 -def tui_command(root: Path, executable: Path) -> list[str] | None: +def tui_command(root: Path, executable: Path, extra: list[str] | None = None) -> list[str] | None: + arguments = [str(executable), *(extra or [])] if os.name == "nt": - return ["cmd.exe", "/d", "/c", "start", "ACECode TUI", "/d", str(root), str(executable)] + return arguments if sys.platform == "darwin": - return ["open", "-a", "Terminal", str(executable)] + shell_command = f"cd {shlex.quote(str(root))} && exec {shlex.join(arguments)}" + script = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(shell_command, ensure_ascii=False) + "\nend tell" + return ["osascript", "-e", script] for terminal in ("x-terminal-emulator", "gnome-terminal", "konsole", "xterm"): path = shutil.which(terminal) if path: if terminal == "gnome-terminal": - return [path, "--", str(executable)] - if terminal == "konsole": - return [path, "-e", str(executable)] - return [path, "-e", str(executable)] + return [path, "--", *arguments] + return [path, "-e", *arguments] return None def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Start an ACECode development environment") + parser = argparse.ArgumentParser(description="Start an ACECode development environment", allow_abbrev=False) parser.add_argument("target", nargs="?", choices=TARGETS, help="development surface to start") parser.add_argument("--build-dir", type=Path, help="build directory to validate and use") parser.add_argument("--yes", action="store_true", help="confirm a required CMake build") @@ -468,12 +590,27 @@ def main() -> int: if not target: return 2 root = project_root() + if target == "desktop" and "--list" in args.extra: + command = [sys.executable, str(root / "scripts/dev_desktop.py"), *args.extra] + if args.build_dir: + command.extend(("--build-dir", str(args.build_dir))) + if args.dry_run: + print("[INFO] Dry run: " + subprocess.list2cmdline(command)) + return 0 + return subprocess.run(command, cwd=root, check=False).returncode sccache = find_sccache() if sccache: print(f"[INFO] Using sccache: {sccache}") else: print(f"[INFO] sccache not found. {sccache_install_hint()}") candidate = find_compatible_build(root, target, args.build_dir) + if args.build_dir and candidate is None: + print(f"[ERROR] --build-dir does not contain a compatible configured {target} build: {(root / args.build_dir).resolve()}", file=sys.stderr) + return 1 + if not args.dry_run and target == "web" and not web_runtime_is_available(root, candidate, args.extra): + return 1 + if not args.dry_run and not ensure_windows_environment(root, candidate): + return 1 sccache_disabled = candidate is not None and sccache_disabled_for_build(candidate.build_dir, sccache) if sccache_disabled: print("[INFO] sccache is disabled for this build after a prior compiler failure.") @@ -512,11 +649,11 @@ def main() -> int: return 1 else: return 1 - if not args.dry_run and not build_target(root, candidate.build_dir, target, sccache): + if not args.dry_run and not build_target(root, candidate.build_dir, target, sccache, candidate.configuration): if sccache: preset = default_preset(target) print("[INFO] sccache build failed; retrying this build without sccache.") - if preset and configure_build(root, preset, candidate.build_dir, None) and build_target(root, candidate.build_dir, target, None): + if preset and configure_build(root, preset, candidate.build_dir, None) and build_target(root, candidate.build_dir, target, None, candidate.configuration): write_sccache_marker(candidate.build_dir, find_sccache(), enabled=False) sccache = None else: @@ -525,9 +662,11 @@ def main() -> int: else: print("[ERROR] Incremental build failed; development environment was not started.", file=sys.stderr) return 1 - if target in {"web", "desktop"} and not args.dry_run and not refresh_web_assets(root): - print("[ERROR] Web asset refresh failed; development environment was not started.", file=sys.stderr) - return 1 + if target in {"web", "desktop"} and not args.dry_run: + refreshed = refresh_web_assets(root, force=True) if target == "desktop" and "--rebuild" in args.extra else refresh_web_assets(root) + if not refreshed: + print("[ERROR] Web asset refresh failed; development environment was not started.", file=sys.stderr) + return 1 return launch_surface(root, target, candidate, args.dry_run, args.extra) diff --git a/scripts/dev_tui.bat b/scripts/dev_tui.bat index 32c84440..4c8d9a3b 100644 --- a/scripts/dev_tui.bat +++ b/scripts/dev_tui.bat @@ -4,8 +4,6 @@ REM Usage: scripts\dev_tui.bat [launcher options] setlocal set "SCRIPT_DIR=%~dp0" -call "%SCRIPT_DIR%dev_windows_env.bat" -if errorlevel 1 exit /b %errorlevel% where python >nul 2>&1 if not errorlevel 1 ( diff --git a/scripts/dev_web.bat b/scripts/dev_web.bat index 52f1681c..6058b479 100644 --- a/scripts/dev_web.bat +++ b/scripts/dev_web.bat @@ -4,8 +4,6 @@ REM Usage: scripts\dev_web.bat [Web daemon options] setlocal set "SCRIPT_DIR=%~dp0" -call "%SCRIPT_DIR%dev_windows_env.bat" -if errorlevel 1 exit /b %errorlevel% where python >nul 2>&1 if not errorlevel 1 ( diff --git a/scripts/dev_web.py b/scripts/dev_web.py index 73c05151..92836d4e 100644 --- a/scripts/dev_web.py +++ b/scripts/dev_web.py @@ -31,7 +31,8 @@ def find_executable(build_dir: Path) -> Path | None: def main() -> int: parser = argparse.ArgumentParser( - description="Start ACECode Web UI daemon without starting the Desktop GUI" + description="Start ACECode Web UI daemon without starting the Desktop GUI", + allow_abbrev=False, ) parser.add_argument("--build-dir", default="build", help="ACECode build directory") parser.add_argument("--cwd", default=None, help="Workspace directory served by the daemon") diff --git a/scripts/dev_windows_env.bat b/scripts/dev_windows_env.bat index 69478ca5..5247d702 100644 --- a/scripts/dev_windows_env.bat +++ b/scripts/dev_windows_env.bat @@ -1,20 +1,26 @@ @echo off -REM Initialize the Visual Studio x64 C++ developer environment for dev launchers. +REM Initialize the native Visual Studio C++ environment when MSVC is required. setlocal EnableDelayedExpansion +set "ACECODE_VSARCH=amd64" +set "ACECODE_VSCOMPONENT=Microsoft.VisualStudio.Component.VC.Tools.x86.x64" +if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "ACECODE_VSARCH=arm64" +if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" set "ACECODE_VSARCH=arm64" +if "!ACECODE_VSARCH!"=="arm64" set "ACECODE_VSCOMPONENT=Microsoft.VisualStudio.Component.VC.Tools.ARM64" set "ACECODE_VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" if not exist "!ACECODE_VSWHERE!" goto :missing_installer set "ACECODE_VSINSTALL=" -for /f "usebackq delims=" %%I in (`"!ACECODE_VSWHERE!" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "ACECODE_VSINSTALL=%%I" +for /f "usebackq delims=" %%I in (`"!ACECODE_VSWHERE!" -latest -products * -requires !ACECODE_VSCOMPONENT! -property installationPath`) do set "ACECODE_VSINSTALL=%%I" if not defined ACECODE_VSINSTALL goto :missing_tools set "ACECODE_VSDEVCMD=!ACECODE_VSINSTALL!\Common7\Tools\VsDevCmd.bat" if not exist "!ACECODE_VSDEVCMD!" goto :missing_command -for %%I in ("!ACECODE_VSDEVCMD!") do endlocal & set "ACECODE_VSDEVCMD=%%~fI" -call "%ACECODE_VSDEVCMD%" -arch=amd64 -host_arch=amd64 >nul +for %%I in ("!ACECODE_VSDEVCMD!") do for %%A in (!ACECODE_VSARCH!) do endlocal & set "ACECODE_VSDEVCMD=%%~fI" & set "ACECODE_VSARCH=%%A" +call "%ACECODE_VSDEVCMD%" -arch=%ACECODE_VSARCH% -host_arch=%ACECODE_VSARCH% >nul if errorlevel 1 goto :initialization_failed +if /i "%~1"=="--print-env" set exit /b 0 :missing_installer diff --git a/tests/scripts/dev_build_artifacts_test.py b/tests/scripts/dev_build_artifacts_test.py index a9aa4e65..4d8f9bee 100644 --- a/tests/scripts/dev_build_artifacts_test.py +++ b/tests/scripts/dev_build_artifacts_test.py @@ -23,6 +23,7 @@ def test_finds_root_and_two_level_artifacts_without_deep_tree_scan(self): for path in (direct, nested, too_deep): path.parent.mkdir(parents=True, exist_ok=True) path.touch() + path.chmod(0o755) self.assertEqual(artifacts.find_named_artifacts(build, ["acecode.exe"]), [direct, nested]) diff --git a/tests/scripts/dev_environment_review_test.py b/tests/scripts/dev_environment_review_test.py new file mode 100644 index 00000000..ff751df2 --- /dev/null +++ b/tests/scripts/dev_environment_review_test.py @@ -0,0 +1,255 @@ +"""Production-path regressions found during development launcher review.""" + +import argparse +import contextlib +import io +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) +import dev_environment as launcher + + +class DevelopmentLauncherReviewTest(unittest.TestCase): + def make_candidate(self, root, configuration=None): + root = root.resolve() + build = root / "build" / "configured" + build.mkdir(parents=True) + cache = "CMAKE_HOME_DIRECTORY:INTERNAL=" + str(root.resolve()) + "\nACECODE_BUILD_DESKTOP:BOOL=ON\n" + if configuration: + cache += "CMAKE_CONFIGURATION_TYPES:STRING=Debug;Release\n" + (build / "CMakeCache.txt").write_text(cache, encoding="utf-8") + output = build / configuration if configuration else build + output.mkdir(exist_ok=True) + executable = output / launcher.native_executable_name("acecode-desktop") + executable.touch() + executable.chmod(0o755) + return launcher.BuildCandidate(build, root, executable, configuration) + + def test_multiconfig_build_and_desktop_launch_use_same_artifact(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + expected = self.make_candidate(root, "Debug") + release = expected.build_dir / "Release" / expected.executable.name + release.parent.mkdir() + release.touch() + release.chmod(0o755) + candidate = launcher.find_compatible_build(root, "desktop") + self.assertEqual(candidate.configuration, "Debug") + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertTrue(launcher.build_target(root, candidate.build_dir, "desktop", None, candidate.configuration)) + self.assertEqual(run.call_args.args[0][-2:], ["--config", "Debug"]) + self.assertEqual(launcher.launch_surface(root, "desktop", candidate, False, []), 0) + command = run.call_args.args[0] + self.assertEqual(command[command.index("--build-dir") + 1], str(expected.executable)) + self.assertNotIn(str(release), command) + + def test_reconfigure_preserves_existing_build_type_and_testing(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = self.make_candidate(root) + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertTrue(launcher.configure_build(root, "ignored", candidate.build_dir, None)) + command = run.call_args.args[0] + self.assertFalse(any(arg.startswith("-DCMAKE_BUILD_TYPE=") or arg.startswith("-DBUILD_TESTING=") for arg in command)) + + def test_relative_build_directory_is_resolved_against_checkout(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + expected = self.make_candidate(root) + actual = launcher.find_compatible_build(root, "desktop", Path("build/configured")) + self.assertEqual(actual.build_dir, expected.build_dir) + + def test_nested_preset_artifact_is_not_attributed_to_parent_build(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + expected = self.make_candidate(root) + parent = root / "build" + (parent / "CMakeCache.txt").write_text( + "CMAKE_HOME_DIRECTORY:INTERNAL=" + str(root) + "\nACECODE_BUILD_DESKTOP:BOOL=ON\n", encoding="utf-8") + self.assertIsNone(launcher.executable_for(parent, "desktop")) + self.assertEqual(launcher.find_compatible_build(root, "desktop").build_dir, expected.build_dir) + + def test_windows_tui_uses_no_shell_and_preserves_arguments(self): + root = Path("C:/dev/ACE & test%PATH%!") + executable = root / "acecode.exe" + candidate = launcher.BuildCandidate(root, root, executable) + extra = ["--cwd", "C:/project with spaces", "--prompt", 'quote " & %PATH% ! $x 中文'] + with patch.object(launcher.os, "name", "nt"), \ + patch.object(launcher.subprocess, "CREATE_NEW_CONSOLE", 0x10, create=True), \ + patch.object(launcher.subprocess, "Popen") as popen: + self.assertEqual(launcher.launch_surface(root, "tui", candidate, False, extra), 0) + self.assertEqual(popen.call_args.args[0], [str(executable), *extra]) + self.assertEqual(popen.call_args.kwargs, {"cwd": root, "creationflags": 0x10}) + + def test_macos_tui_preserves_cwd_and_shell_characters(self): + root = Path("/tmp/ACE ' & workspace") + executable = root / "acecode" + extra = ["--prompt", 'literal `echo wrong` $(echo wrong) "quotes" 中文'] + with patch.object(launcher.os, "name", "posix"), patch.object(launcher.sys, "platform", "darwin"): + command = launcher.tui_command(root, executable, extra) + self.assertEqual(command[:2], ["osascript", "-e"]) + quoted_command = command[2].split("do script ", 1)[1].split("\nend tell", 1)[0] + self.assertEqual(shlex.split(json.loads(quoted_command)), ["cd", str(root), "&&", "exec", str(executable), *extra]) + + def test_linux_tui_forwards_each_argument(self): + root = Path("/tmp/ACE & workspace") + for terminal, separator in (("gnome-terminal", "--"), ("konsole", "-e"), ("xterm", "-e")): + with self.subTest(terminal=terminal), patch.object(launcher.os, "name", "posix"), \ + patch.object(launcher.sys, "platform", "linux"), \ + patch.object(launcher.shutil, "which", side_effect=lambda name: f"/usr/bin/{name}" if name == terminal else None): + self.assertEqual(launcher.tui_command(root, root / "acecode", ["--prompt", "two words"]), + [f"/usr/bin/{terminal}", separator, str(root / "acecode"), "--prompt", "two words"]) + + def test_surface_script_failure_is_returned(self): + root = Path("C:/work") + candidate = launcher.BuildCandidate(root, root, root / "acecode-desktop.exe") + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 7)): + self.assertEqual(launcher.launch_surface(root, "desktop", candidate, False, ["--invalid"]), 7) + + def test_mingw_is_compatible_and_does_not_require_visual_studio(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = self.make_candidate(root) + cache = candidate.build_dir / "CMakeCache.txt" + cache.write_text(cache.read_text(encoding="utf-8") + "VCPKG_TARGET_TRIPLET:STRING=x64-mingw-static\n", encoding="utf-8") + with patch.object(launcher.platform, "system", return_value="Windows"), \ + patch.object(launcher, "native_machine", return_value="amd64"), \ + patch.object(launcher.subprocess, "run") as run: + self.assertTrue(launcher.platform_matches(candidate.build_dir)) + self.assertFalse(launcher.needs_msvc(candidate)) + self.assertTrue(launcher.ensure_windows_environment(root, candidate)) + run.assert_not_called() + + def test_every_default_preset_exists(self): + presets = {item["name"] for item in json.loads((ROOT / "CMakePresets.json").read_text(encoding="utf-8"))["configurePresets"]} + for system in ("Windows", "Darwin", "Linux"): + for machine in ("amd64", "arm64"): + for target in launcher.TARGETS: + with self.subTest(system=system, machine=machine, target=target), \ + patch.object(launcher.platform, "system", return_value=system), \ + patch.object(launcher, "native_machine", return_value=machine): + self.assertIn(launcher.default_preset(target), presets) + + def test_arm64_windows_emulation_selects_native_architecture(self): + with patch.object(launcher.platform, "system", return_value="Windows"), \ + patch.object(launcher.platform, "machine", return_value="AMD64"), \ + patch.dict(os.environ, {"PROCESSOR_ARCHITECTURE": "AMD64", "PROCESSOR_ARCHITEW6432": "ARM64"}): + self.assertEqual(launcher.default_preset("desktop"), "windows-arm64-desktop-release") + + @unittest.skipUnless(os.name == "nt", "Windows environment setup") + def test_windows_environment_helper_preserves_metacharacters_in_path(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "ACE & %PATH% ! repo" + (root / "scripts").mkdir(parents=True) + (root / "scripts/dev_windows_env.bat").write_text( + "@echo off\nset ACECODE_TEST_ENV=ready\nset\n", encoding="ascii") + with patch.dict(os.environ, {}, clear=False): + self.assertTrue(launcher.ensure_windows_environment(root, None)) + self.assertEqual(os.environ.get("ACECODE_TEST_ENV"), "ready") + + @unittest.skipUnless(os.name == "nt", "Windows environment setup") + def test_missing_msvc_environment_fails_before_build(self): + root = Path("C:/missing-tools") + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, stdout="[ERROR] Missing C++ tools", stderr="")), \ + contextlib.redirect_stderr(io.StringIO()) as output: + self.assertFalse(launcher.ensure_windows_environment(root, None)) + self.assertIn("Missing C++ tools", output.getvalue()) + + def test_existing_runtime_only_runs_identity_check_never_stop(self): + for status in (0, 1): + with self.subTest(status=status), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = self.make_candidate(root) + run_dir = root / "explicit-runtime" + run_dir.mkdir() + (run_dir / "daemon.pid").write_text("123", encoding="ascii") + error_output = io.StringIO() + with patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], status)) as run, \ + contextlib.redirect_stderr(error_output): + self.assertFalse(launcher.web_runtime_is_available(root, candidate, [f"--run-dir={run_dir}"])) + self.assertEqual(run.call_args.args[0][1:3], ["daemon", "status"]) + self.assertEqual(run.call_args.kwargs["timeout"], 10) + self.assertEqual((run_dir / "daemon.pid").read_text(encoding="ascii"), "123") + self.assertIn("Stop this verified daemon" if status == 0 else "Runtime identity is unverified", error_output.getvalue()) + + def test_runtime_failure_prevents_configuration_build_and_launch(self): + root = Path("C:/work") + candidate = launcher.BuildCandidate(root, root, root / "acecode.exe") + args = argparse.Namespace(target="web", build_dir=None, yes=True, dry_run=False, extra=[]) + with patch.object(launcher, "parse_args", return_value=args), \ + patch.object(launcher, "project_root", return_value=root), \ + patch.object(launcher, "find_sccache", return_value=None), \ + patch.object(launcher, "find_compatible_build", return_value=candidate), \ + patch.object(launcher, "web_runtime_is_available", return_value=False), \ + patch.object(launcher, "ensure_windows_environment") as environment, \ + patch.object(launcher, "configure_build") as configure, \ + patch.object(launcher, "build_target") as build, \ + patch.object(launcher, "launch_surface") as launch: + self.assertEqual(launcher.main(), 1) + environment.assert_not_called() + configure.assert_not_called() + build.assert_not_called() + launch.assert_not_called() + + def test_runtime_from_previous_commit_also_blocks_rebuild(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + candidate = self.make_candidate(root) + previous = root / ".acecode/dev-run" / (root.name + "-oldcommit") + previous.mkdir(parents=True) + (previous / "daemon.pid").write_text("123", encoding="ascii") + with patch.object(launcher, "current_commit", return_value="newcommit"), \ + patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run, \ + contextlib.redirect_stderr(io.StringIO()): + self.assertFalse(launcher.web_runtime_is_available(root, candidate, [])) + self.assertEqual(run.call_args.args[0][-1], f"--run-dir={previous}") + + def test_explicit_runtime_does_not_inspect_other_launcher_daemons(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + candidate = self.make_candidate(root) + previous = root / ".acecode/dev-run" / (root.name + "-oldcommit") + previous.mkdir(parents=True) + (previous / "daemon.pid").write_text("123", encoding="ascii") + with patch.object(launcher.subprocess, "run") as run: + self.assertTrue(launcher.web_runtime_is_available(root, candidate, ["--run-dir", "custom-runtime"])) + run.assert_not_called() + + def test_desktop_list_does_not_build_or_initialize_toolchain(self): + args = argparse.Namespace(target="desktop", build_dir=Path("build/custom"), yes=True, dry_run=False, extra=["--list"]) + with patch.object(launcher, "parse_args", return_value=args), \ + patch.object(launcher, "find_sccache") as discover, \ + patch.object(launcher.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)) as run: + self.assertEqual(launcher.main(), 0) + discover.assert_not_called() + self.assertIn("--list", run.call_args.args[0]) + + def test_force_web_rebuild_does_not_reuse_existing_assets(self): + builder = Mock() + builder.ensure_node_and_pnpm.return_value = ("node", "pnpm") + root = Path("C:/work") + with patch.object(launcher, "load_web_builder", return_value=builder), \ + patch.object(launcher, "seed_web_assets") as seed: + self.assertTrue(launcher.refresh_web_assets(root, force=True)) + seed.assert_not_called() + builder.build_web.assert_called_once_with(root / "web", "pnpm", force=True) + + def test_desktop_list_dry_run_does_not_spawn_a_process(self): + args = argparse.Namespace(target="desktop", build_dir=None, yes=False, dry_run=True, extra=["--list"]) + with patch.object(launcher, "parse_args", return_value=args), \ + patch.object(launcher.subprocess, "run") as run: + self.assertEqual(launcher.main(), 0) + run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/scripts/dev_environment_test.py b/tests/scripts/dev_environment_test.py index 09ac885e..636b6463 100644 --- a/tests/scripts/dev_environment_test.py +++ b/tests/scripts/dev_environment_test.py @@ -18,6 +18,11 @@ class DevEnvironmentTest(unittest.TestCase): + def setUp(self): + environment = patch.object(dev_environment, "ensure_windows_environment", return_value=True) + environment.start() + self.addCleanup(environment.stop) + def make_build(self, root, desktop=False, name="test"): build = root / "build" / name build.mkdir(parents=True) @@ -28,8 +33,10 @@ def make_build(self, root, desktop=False, name="test"): encoding="utf-8", ) (build / dev_environment.native_executable_name("acecode")).touch() + (build / dev_environment.native_executable_name("acecode")).chmod(0o755) if desktop: (build / dev_environment.native_executable_name("acecode-desktop")).touch() + (build / dev_environment.native_executable_name("acecode-desktop")).chmod(0o755) return build def test_web_candidate_prefers_non_desktop_build(self): @@ -75,8 +82,7 @@ def test_tui_command_uses_new_windows_terminal(self): executable = Path("C:/work/build/acecode.exe") with patch.object(dev_environment.os, "name", "nt"): command = dev_environment.tui_command(Path("C:/work"), executable) - self.assertEqual(command[:4], ["cmd.exe", "/d", "/c", "start"]) - self.assertIn(str(executable), command) + self.assertEqual(command, [str(executable)]) def test_runtime_directory_is_scoped_to_worktree(self): root = Path("C:/worktrees/my project") @@ -156,9 +162,9 @@ def test_existing_build_reconfiguration_targets_candidate_directory(self): def test_newest_web_seed_requires_clean_same_commit_and_uses_newest_output(self): with tempfile.TemporaryDirectory() as directory: - root = Path(directory) / "current" - older = Path(directory) / "older" - newer = Path(directory) / "newer" + root = Path(directory).resolve() / "current" + older = Path(directory).resolve() / "older" + newer = Path(directory).resolve() / "newer" for worktree in (root, older, newer): (worktree / "web/dist").mkdir(parents=True) (worktree / "web/dist/index.html").write_text("ok", encoding="utf-8") @@ -219,7 +225,7 @@ def test_main_refreshes_build_and_web_assets_before_web_start(self): patch.object(dev_environment, "refresh_web_assets", return_value=True) as refresh, \ patch.object(dev_environment, "launch_surface", return_value=0) as launch: self.assertEqual(dev_environment.main(), 0) - build.assert_called_once_with(Path("C:/work"), candidate.build_dir, "web", None) + build.assert_called_once_with(Path("C:/work"), candidate.build_dir, "web", None, None) refresh.assert_called_once_with(Path("C:/work")) launch.assert_called_once_with(Path("C:/work"), "web", candidate, False, []) @@ -243,8 +249,8 @@ def test_main_retries_failed_sccache_build_without_cache(self): patch.object(dev_environment, "refresh_web_assets", return_value=True), \ patch.object(dev_environment, "launch_surface", return_value=0): self.assertEqual(dev_environment.main(), 0) - self.assertEqual(build.call_args_list[0].args, (Path("C:/work"), candidate.build_dir, "web", cache)) - self.assertEqual(build.call_args_list[1].args, (Path("C:/work"), candidate.build_dir, "web", None)) + self.assertEqual(build.call_args_list[0].args, (Path("C:/work"), candidate.build_dir, "web", cache, None)) + self.assertEqual(build.call_args_list[1].args, (Path("C:/work"), candidate.build_dir, "web", None, None)) configure.assert_called_once_with(Path("C:/work"), "windows-x64-release", candidate.build_dir, None) def test_web_launch_forwards_the_build_and_isolated_runtime_directory(self): diff --git a/tests/scripts/dev_web_test.py b/tests/scripts/dev_web_test.py index f3709629..377a8ac2 100644 --- a/tests/scripts/dev_web_test.py +++ b/tests/scripts/dev_web_test.py @@ -23,9 +23,11 @@ def test_finds_platform_executable(self): executable = root / "Release" / name executable.parent.mkdir() executable.touch() + executable.chmod(0o755) self.assertEqual(dev_web.find_executable(root), executable) direct = root / name direct.touch() + direct.chmod(0o755) self.assertEqual(dev_web.find_executable(root), direct) def run_launcher(self, exit_code, port):