mirror of
https://github.com/blw1138/cross-py-builder.git
synced 2026-09-07 21:41:09 -05:00
Add controller with web UI and retire CLI
- Add ctrl/ package: Flask app (waitress), SQLite job store, background scheduler that probes configured workers, dispatches via /upload and /checkout_git, fetches artifacts, and serves a single-page UI - Supporting API: /api/jobs (create/list/detail/cancel), /api/workers, /api/capabilities, artifact download, and SSE log stream - Workers keep the existing HTTP API; add /progress/<job_id> endpoint and per-step build tracking for live status - Retire agent_manager.py and the cross-py-builder CLI; controller UI is the primary frontend - Record design in DESIGN.md; add waitress dependency Note: /progress is exposed on the worker but the synchronous /upload and /checkout endpoints block until a build completes, so per-step worker progress is not yet relayed live in the UI. Live streaming needs an async-start worker endpoint as a follow-up.
This commit is contained in:
@@ -32,6 +32,20 @@ BUILD_DIR = "pybuild-data"
|
||||
TMP_DIR = tempfile.gettempdir()
|
||||
system_status = {"status": "ready", "running_job": None}
|
||||
build_lock = threading.Lock()
|
||||
progress_lock = threading.Lock()
|
||||
build_progress = {}
|
||||
|
||||
def _set_step(job_id, step, **extra):
|
||||
with progress_lock:
|
||||
build_progress[job_id] = dict(build_progress.get(job_id, {}),
|
||||
job_id=job_id,
|
||||
step=step,
|
||||
step_started_at=datetime.datetime.now().isoformat(),
|
||||
**extra)
|
||||
|
||||
def _clear_progress(job_id):
|
||||
with progress_lock:
|
||||
build_progress.pop(job_id, None)
|
||||
|
||||
def is_windows():
|
||||
return platform.system().lower() == "windows"
|
||||
@@ -167,6 +181,28 @@ def status():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/progress/<job_id>', methods=['GET'])
|
||||
def progress(job_id):
|
||||
if not is_valid_job_id(job_id):
|
||||
return jsonify({"error": f"Invalid job id: {job_id}"}), 404
|
||||
|
||||
with progress_lock:
|
||||
info = dict(build_progress.get(job_id)) if job_id in build_progress else None
|
||||
|
||||
if not info:
|
||||
return jsonify({"status": "not_found", "job_id": job_id}), 404
|
||||
|
||||
info['elapsed'] = ""
|
||||
step_started = info.get('step_started_at')
|
||||
if step_started:
|
||||
try:
|
||||
started = datetime.datetime.fromisoformat(step_started)
|
||||
info['elapsed'] = str(datetime.datetime.now() - started)
|
||||
except ValueError:
|
||||
pass
|
||||
return jsonify(info)
|
||||
|
||||
|
||||
def generate_job_id():
|
||||
return str(uuid.uuid4()).split('-')[-1]
|
||||
|
||||
@@ -193,6 +229,7 @@ def checkout_project():
|
||||
start_time = datetime.datetime.now()
|
||||
try:
|
||||
os.makedirs(build_root, exist_ok=True)
|
||||
_set_step(job_id, "cloning_repo", repo_url=repo_url)
|
||||
system_status['status'] = "cloning_repo"
|
||||
subprocess.check_call(['git', 'clone', repo_url, repo_dir])
|
||||
system_status['status'] = "ready"
|
||||
@@ -201,6 +238,7 @@ def checkout_project():
|
||||
print(f"Error processing checkout: {e}")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(repo_dir, ignore_errors=True)
|
||||
if isinstance(e, subprocess.CalledProcessError):
|
||||
return jsonify({'error': 'Failed to clone repository'}), 500
|
||||
@@ -233,6 +271,7 @@ def upload_project():
|
||||
working_dir = os.path.join(TMP_DIR, BUILD_DIR, job_id)
|
||||
|
||||
file = request.files['file']
|
||||
_set_step(job_id, "processing_files", source=file.filename)
|
||||
zip_path = os.path.join(working_dir, "source.zip")
|
||||
|
||||
# Save ZIP file
|
||||
@@ -248,25 +287,29 @@ def upload_project():
|
||||
except Exception as e:
|
||||
print(f"Uncaught error processing job: {e}")
|
||||
system_status['status'] = "ready"
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(working_dir, ignore_errors=True)
|
||||
return jsonify({"error": f"Uncaught error processing job: {e}"}), 500
|
||||
finally:
|
||||
build_lock.release()
|
||||
|
||||
def install_and_build(project_path, job_id, start_time):
|
||||
_set_step(job_id, "starting", project_path=project_path)
|
||||
|
||||
# Find the PyInstaller spec file
|
||||
spec_files = glob.glob(os.path.join(project_path, "*.spec"))
|
||||
if not spec_files:
|
||||
_clear_progress(job_id)
|
||||
return jsonify({"error": "No .spec files found"}), 400
|
||||
|
||||
print(f"Starting new build job - {len(spec_files)} spec files found")
|
||||
system_status['status'] = "working"
|
||||
system_status['running_job'] = os.path.basename(project_path)
|
||||
system_status['running_job'] = job_id
|
||||
|
||||
# Set up virtual environment
|
||||
venv_path = os.path.join(project_path, "venv")
|
||||
try:
|
||||
_set_step(job_id, "creating_venv")
|
||||
system_status['status'] = "creating_venv"
|
||||
print(f"\n========== Configuring Virtual Environment ({venv_path}) ==========")
|
||||
python_exec = "python" if is_windows() else "python3"
|
||||
@@ -279,11 +322,13 @@ def install_and_build(project_path, job_id, start_time):
|
||||
print(f"Error setting up virtual environment: {e}")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(project_path, ignore_errors=True)
|
||||
return jsonify({"error": f"Error setting up virtual environment: {e}"}), 500
|
||||
|
||||
# Install requirements
|
||||
try:
|
||||
_set_step(job_id, "installing_packages")
|
||||
system_status['status'] = "installing_packages"
|
||||
subprocess.run([py_exec, "-m", "pip", "install", "--upgrade", "pip"], check=True)
|
||||
subprocess.run([py_exec, "-m", "pip", "install", "pyinstaller", "pyinstaller_versionfile", "--prefer-binary"], check=True)
|
||||
@@ -296,13 +341,17 @@ def install_and_build(project_path, job_id, start_time):
|
||||
print(f"Error installing requirements: {e}")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(project_path, ignore_errors=True)
|
||||
return jsonify({"error": f"Error installing requirements: {e}"}), 500
|
||||
|
||||
results = {}
|
||||
try:
|
||||
spec_total = len(spec_files)
|
||||
for index, spec_file in enumerate(spec_files):
|
||||
# Compile with PyInstaller
|
||||
_set_step(job_id, "compiling", spec_index=index, spec_total=spec_total,
|
||||
spec_name=os.path.basename(spec_file))
|
||||
system_status['status'] = "compiling"
|
||||
print(f"\n========== Compiling spec file {index+1} of {len(spec_files)} - {spec_file} ==========")
|
||||
simple_name = os.path.splitext(os.path.basename(spec_file))[0]
|
||||
@@ -322,22 +371,30 @@ def install_and_build(project_path, job_id, start_time):
|
||||
print(line, end="")
|
||||
log_file.write(line)
|
||||
log_file.flush()
|
||||
with progress_lock:
|
||||
if job_id in build_progress:
|
||||
build_progress[job_id]['last_log_line'] = line.rstrip()
|
||||
process.wait()
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"PyInstaller failed with exit code {process.returncode}. Last line: {str(last_line).strip()}")
|
||||
|
||||
print(f"\n========== Compilation of spec file {spec_file} complete ==========\n")
|
||||
with progress_lock:
|
||||
if job_id in build_progress:
|
||||
build_progress[job_id]['spec_completed'] = index + 1
|
||||
except Exception as e:
|
||||
print(f"Error compiling project: {e}")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
shutil.rmtree(project_path, ignore_errors=True)
|
||||
return jsonify({"error": f"Error compiling project: {e}"}), 500
|
||||
|
||||
dist_path = os.path.join(project_path, "dist")
|
||||
system_status['status'] = "ready"
|
||||
system_status['running_job'] = None
|
||||
_clear_progress(job_id)
|
||||
return jsonify({
|
||||
"id": job_id,
|
||||
"message": "Build completed",
|
||||
|
||||
Reference in New Issue
Block a user