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:
Brett Williams
2026-08-30 21:55:46 -05:00
parent 5f0d2db1a9
commit 0e89919342
14 changed files with 1237 additions and 342 deletions
+58 -1
View File
@@ -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",