From 68907faee9522ec46cc8f61788051eed599f631f Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Fri, 10 Apr 2026 17:32:12 +0200 Subject: [PATCH 01/34] feat: add Import Plan button and upload page Adds "Import Plan" button to /plan list page (top-right corner). Links to /plan/import with a zip file upload form. The uploaded file is accepted but not yet processed (placeholder for next step). Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/src/plan_routes.py | 21 +++++ .../templates/plan_import.html | 92 +++++++++++++++++++ frontend_multi_user/templates/plan_list.html | 5 +- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 frontend_multi_user/templates/plan_import.html diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 6744bb95..1514e8c4 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -949,6 +949,27 @@ def plan(): ) +@plan_routes_bp.route("/plan/import", methods=["GET", "POST"]) +@login_required +def plan_import(): + message = None + message_type = None + if request.method == "POST": + zip_file = request.files.get("zip_file") + if zip_file is None or zip_file.filename == "": + message = "No file selected." + message_type = "error" + elif not zip_file.filename.endswith(".zip"): + message = "Please upload a .zip file." + message_type = "error" + else: + # TODO: process the zip file + logger.info("Plan import: received zip file %r (%s bytes)", zip_file.filename, zip_file.content_length) + message = f"Uploaded {zip_file.filename} (not yet processed)." + message_type = "success" + return render_template("plan_import.html", message=message, message_type=message_type) + + @plan_routes_bp.route("/plan/stop", methods=["POST"]) @login_required def plan_stop(): diff --git a/frontend_multi_user/templates/plan_import.html b/frontend_multi_user/templates/plan_import.html new file mode 100644 index 00000000..3c921440 --- /dev/null +++ b/frontend_multi_user/templates/plan_import.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}Import Plan - PlanExe{% endblock %} +{% block head %} + +{% endblock %} + +{% block content %} +← Back to Plans +

Import Plan

+

Upload a plan zip file to import it.

+ +
+
+ + + + +
+ + {% if message %} +
+ {{ message }} +
+ {% endif %} +
+{% endblock %} diff --git a/frontend_multi_user/templates/plan_list.html b/frontend_multi_user/templates/plan_list.html index 43a0f043..949f7d6c 100644 --- a/frontend_multi_user/templates/plan_list.html +++ b/frontend_multi_user/templates/plan_list.html @@ -131,7 +131,10 @@ {% endblock %} {% block content %} -

Plans

+
+

Plans

+ Import Plan +

Technical queue view · newest first · click row to inspect

{% if plan_rows %} From b10477f4dc8dd558cc2cb469366984c241ba7846 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 11 Apr 2026 22:36:19 +0200 Subject: [PATCH 02/34] feat: create PlanItem on zip upload with import_pending state - Add import_pending state to PlanState enum - Upload stores zip in run_zip_snapshot, creates PlanItem, redirects to plan view - 50 MB upload limit - Purple status chip for import_pending in plan list Co-Authored-By: Claude Opus 4.6 (1M context) --- database_api/model_planitem.py | 1 + frontend_multi_user/src/plan_routes.py | 27 +++++++++++++++++--- frontend_multi_user/templates/plan_list.html | 5 ++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/database_api/model_planitem.py b/database_api/model_planitem.py index 300df75a..797a5e17 100644 --- a/database_api/model_planitem.py +++ b/database_api/model_planitem.py @@ -38,6 +38,7 @@ class PlanState(enum.Enum): completed = 3 failed = 4 stopped = 5 + import_pending = 6 class PlanItem(db.Model): diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 1514e8c4..20c2aca0 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -963,10 +963,29 @@ def plan_import(): message = "Please upload a .zip file." message_type = "error" else: - # TODO: process the zip file - logger.info("Plan import: received zip file %r (%s bytes)", zip_file.filename, zip_file.content_length) - message = f"Uploaded {zip_file.filename} (not yet processed)." - message_type = "success" + zip_data = zip_file.read() + zip_size = len(zip_data) + max_zip_size = 50 * 1024 * 1024 # 50 MB + if zip_size > max_zip_size: + message = f"Zip file too large ({zip_size / 1024 / 1024:.1f} MB). Maximum is {max_zip_size // 1024 // 1024} MB." + message_type = "error" + else: + user_id = str(current_user.id) + plan = PlanItem( + prompt=f"[Imported from {zip_file.filename}]", + state=PlanState.import_pending, + user_id=user_id, + parameters={ + "trigger_source": "frontend import", + "import_filename": zip_file.filename, + "pipeline_version": PIPELINE_VERSION, + }, + run_zip_snapshot=zip_data, + ) + db.session.add(plan) + db.session.commit() + logger.info("Plan import: created plan %s from %r (%s bytes) for user %s", plan.id, zip_file.filename, zip_size, user_id) + return redirect(url_for("plan_routes.plan", id=str(plan.id))) return render_template("plan_import.html", message=message, message_type=message_type) diff --git a/frontend_multi_user/templates/plan_list.html b/frontend_multi_user/templates/plan_list.html index 949f7d6c..36472193 100644 --- a/frontend_multi_user/templates/plan_list.html +++ b/frontend_multi_user/templates/plan_list.html @@ -103,6 +103,11 @@ color: #e65100; background: #fff3e0; } + .status-chip.status-import_pending { + border-color: #6a1b9a; + color: #6a1b9a; + background: #f3e5f5; + } .plan-cell-prompt { white-space: nowrap; From eb2840a028f56d5e33e858ffc1561fa7e33873dd Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 11 Apr 2026 22:51:42 +0200 Subject: [PATCH 03/34] fix: add import_pending to PostgreSQL enum via migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python enum change alone isn't enough — PostgreSQL has its own enum type that needs ALTER TYPE ADD VALUE. Added to both frontend_multi_user and worker_plan_database startup migrations. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/src/app.py | 11 ++++++----- worker_plan_database/app.py | 9 +++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend_multi_user/src/app.py b/frontend_multi_user/src/app.py index cd2661c7..965c2e51 100644 --- a/frontend_multi_user/src/app.py +++ b/frontend_multi_user/src/app.py @@ -531,11 +531,12 @@ def _ensure_stopped_state() -> None: one enum name does not poison the attempt for the other. """ for type_name in ("taskstate", "planstate"): - try: - with self.db.engine.begin() as conn: - conn.execute(text(f"ALTER TYPE {type_name} ADD VALUE IF NOT EXISTS 'stopped'")) - except Exception as exc: - logger.debug("ALTER TYPE %s: %s", type_name, exc) + for enum_value in ("stopped", "import_pending"): + try: + with self.db.engine.begin() as conn: + conn.execute(text(f"ALTER TYPE {type_name} ADD VALUE IF NOT EXISTS '{enum_value}'")) + except Exception as exc: + logger.debug("ALTER TYPE %s ADD VALUE %s: %s", type_name, enum_value, exc) def _ensure_last_progress_at_column() -> None: insp = inspect(self.db.engine) diff --git a/worker_plan_database/app.py b/worker_plan_database/app.py index e486f0a1..0a1ba7a4 100644 --- a/worker_plan_database/app.py +++ b/worker_plan_database/app.py @@ -360,12 +360,13 @@ def ensure_stopped_state() -> None: the TaskState → PlanState Python rename (proposal 74). Fresh databases created after that rename will have ``planstate``. We try both names. """ - with db.engine.begin() as conn: - for type_name in ("taskstate", "planstate"): + for type_name in ("taskstate", "planstate"): + for enum_value in ("stopped", "import_pending"): try: - conn.execute(text(f"ALTER TYPE {type_name} ADD VALUE IF NOT EXISTS 'stopped'")) + with db.engine.begin() as conn: + conn.execute(text(f"ALTER TYPE {type_name} ADD VALUE IF NOT EXISTS '{enum_value}'")) except Exception as exc: - logger.debug("ALTER TYPE %s: %s", type_name, exc) + logger.debug("ALTER TYPE %s ADD VALUE %s: %s", type_name, enum_value, exc) def worker_process_started() -> None: planexe_worker_id = os.environ.get("PLANEXE_WORKER_ID") From ea0dbeb9e9de284657a2aaa0e3a670996ae1baa6 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 11 Apr 2026 23:09:33 +0200 Subject: [PATCH 04/34] fix: reduce max zip upload size to 10 MB Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/src/plan_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 20c2aca0..4ac0cab5 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -965,7 +965,7 @@ def plan_import(): else: zip_data = zip_file.read() zip_size = len(zip_data) - max_zip_size = 50 * 1024 * 1024 # 50 MB + max_zip_size = 10 * 1024 * 1024 # 10 MB if zip_size > max_zip_size: message = f"Zip file too large ({zip_size / 1024 / 1024:.1f} MB). Maximum is {max_zip_size // 1024 // 1024} MB." message_type = "error" From 1d44a0752eeaa3f193c215900f15cefc9103ee29 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 11 Apr 2026 23:11:52 +0200 Subject: [PATCH 05/34] feat: full-page drag-and-drop for plan import Drop zone covers the main area. Click to browse or drag a .zip file. Upload button appears after file is selected. Page-level dragover prevented so the browser doesn't navigate away on accidental drops. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../templates/plan_import.html | 118 ++++++++++++++---- 1 file changed, 96 insertions(+), 22 deletions(-) diff --git a/frontend_multi_user/templates/plan_import.html b/frontend_multi_user/templates/plan_import.html index 3c921440..53d7aefd 100644 --- a/frontend_multi_user/templates/plan_import.html +++ b/frontend_multi_user/templates/plan_import.html @@ -12,26 +12,45 @@ font-size: 0.82rem; color: var(--color-text-secondary); } - .import-wrap { - border: 1px solid var(--color-border); + .import-dropzone { + border: 2px dashed var(--color-border); border-radius: var(--radius-lg); background: var(--color-card-bg); - padding: 24px; + padding: 48px 24px; max-width: 500px; + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; } - .import-label { - display: block; - font-size: 0.82rem; - font-weight: 600; + .import-dropzone.drag-over { + border-color: var(--color-primary, #0066cc); + background: #e3f2fd; + } + .import-dropzone-icon { + font-size: 2rem; margin-bottom: 8px; - color: var(--color-text); + color: var(--color-text-secondary); } - .import-file-input { - display: block; - margin-bottom: 16px; + .import-dropzone-text { + font-size: 0.9rem; + color: var(--color-text-secondary); + margin-bottom: 4px; + } + .import-dropzone-hint { + font-size: 0.75rem; + color: var(--color-text-secondary); + opacity: 0.7; + } + .import-dropzone-filename { + margin-top: 12px; font-size: 0.82rem; + font-weight: 600; + color: var(--color-text); + display: none; } .import-submit { + display: none; + margin-top: 16px; padding: 8px 20px; font-size: 0.82rem; font-weight: 600; @@ -40,6 +59,8 @@ border: none; border-radius: var(--radius-lg); cursor: pointer; + max-width: 500px; + width: 100%; } .import-submit:hover { opacity: 0.9; @@ -56,6 +77,7 @@ padding: 10px 14px; border-radius: var(--radius-lg); font-size: 0.82rem; + max-width: 500px; } .import-message-success { background: #e6f4ea; @@ -75,18 +97,70 @@

Import Plan

Upload a plan zip file to import it.

-
-
- - - - -
+
+ + - {% if message %} -
- {{ message }} +
+
📦
+
Drop a .zip file here or click to browse
+
.zip files only, max 10 MB
+
- {% endif %} + + + +{% if message %} +
+ {{ message }}
+{% endif %} + + {% endblock %} From d92d4593ea3676bf6bac632ac8fd8bb3fc6ecf71 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 11 Apr 2026 23:20:11 +0200 Subject: [PATCH 06/34] fix: handle import errors gracefully, bump gunicorn timeout to 120s The gunicorn worker was timing out (60s) during zip upload + DB insert. Bumped to 120s. Also added try/except around the DB commit so import errors show a user-visible message instead of crashing the worker. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/Dockerfile | 2 +- frontend_multi_user/src/plan_routes.py | 41 ++++++++++++++++---------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/frontend_multi_user/Dockerfile b/frontend_multi_user/Dockerfile index 37812eb5..18966fad 100644 --- a/frontend_multi_user/Dockerfile +++ b/frontend_multi_user/Dockerfile @@ -37,5 +37,5 @@ ENV PLANEXE_FRONTEND_MULTIUSER_WORKERS=4 CMD gunicorn wsgi:app \ --bind 0.0.0.0:${PLANEXE_FRONTEND_MULTIUSER_PORT:-5000} \ --workers ${PLANEXE_FRONTEND_MULTIUSER_WORKERS} \ - --timeout 60 \ + --timeout 120 \ --chdir /app/frontend_multi_user/src diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 4ac0cab5..9f3b4c5e 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -970,22 +970,31 @@ def plan_import(): message = f"Zip file too large ({zip_size / 1024 / 1024:.1f} MB). Maximum is {max_zip_size // 1024 // 1024} MB." message_type = "error" else: - user_id = str(current_user.id) - plan = PlanItem( - prompt=f"[Imported from {zip_file.filename}]", - state=PlanState.import_pending, - user_id=user_id, - parameters={ - "trigger_source": "frontend import", - "import_filename": zip_file.filename, - "pipeline_version": PIPELINE_VERSION, - }, - run_zip_snapshot=zip_data, - ) - db.session.add(plan) - db.session.commit() - logger.info("Plan import: created plan %s from %r (%s bytes) for user %s", plan.id, zip_file.filename, zip_size, user_id) - return redirect(url_for("plan_routes.plan", id=str(plan.id))) + try: + user_id = str(current_user.id) + plan = PlanItem( + prompt=f"[Imported from {zip_file.filename}]", + state=PlanState.import_pending, + user_id=user_id, + parameters={ + "trigger_source": "frontend import", + "import_filename": zip_file.filename, + "pipeline_version": PIPELINE_VERSION, + }, + run_zip_snapshot=zip_data, + ) + db.session.add(plan) + db.session.commit() + logger.info( + "Plan import: created plan %s from %r (%s bytes) for user %s", + plan.id, zip_file.filename, zip_size, user_id, + ) + return redirect(url_for("plan_routes.plan", id=str(plan.id))) + except Exception as exc: + db.session.rollback() + logger.error("Plan import failed for %r: %s", zip_file.filename, exc) + message = "Import failed. Please try again." + message_type = "error" return render_template("plan_import.html", message=message, message_type=message_type) From 02a556a2b89b1376ca6d9a288bc77c1743d541ac Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 11 Apr 2026 23:29:18 +0200 Subject: [PATCH 07/34] feat: validate and clean imported zip before storing - Verify zip is valid (rejects corrupt/non-zip files) - Remove ExtraFilenameEnum files (log.txt, track_activity.jsonl, etc.) - Verify all remaining files match FilenameEnum (including templates) - Reject zips with unrecognized files - Store the cleaned zip in run_zip_snapshot Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/src/plan_routes.py | 132 ++++++++++++++++++++----- 1 file changed, 108 insertions(+), 24 deletions(-) diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 9f3b4c5e..5721756c 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -949,6 +949,85 @@ def plan(): ) +def _validate_and_clean_import_zip(zip_data: bytes) -> dict: + """Validate and clean an uploaded zip for plan import. + + Returns dict with keys: + error: str or None — error message if invalid + cleaned_zip: bytes or None — cleaned zip data if valid + """ + # Verify it's a valid zip + try: + zf = zipfile.ZipFile(io.BytesIO(zip_data)) + except (zipfile.BadZipFile, Exception) as exc: + return {"error": f"Invalid zip file: {exc}", "cleaned_zip": None} + + # Build the set of allowed filenames from FilenameEnum + allowed_filenames: set[str] = set() + for member in FilenameEnum: + value = member.value + if "{}" in value: + # Template filenames like "expert_criticism_{}_raw.json" — skip exact match, + # we'll match these by suffix below + continue + allowed_filenames.add(value) + allowed_filenames.add("pipeline_complete.txt") + + # Template suffixes for filenames with {} placeholders + template_suffixes: list[str] = [] + for member in FilenameEnum: + value = member.value + if "{}" in value: + # e.g. "expert_criticism_{}_raw.json" -> "_raw.json" after the placeholder + suffix = value.split("{}")[1] + prefix = value.split("{}")[0] + template_suffixes.append((prefix, suffix)) + + files_to_delete = {e.value for e in ExtraFilenameEnum} + unrecognized = [] + + for info in zf.infolist(): + # Skip directories + if info.is_dir(): + continue + name = info.filename + # Strip a single leading directory if present (e.g. "run_id/plan.txt" -> "plan.txt") + basename = name.split("/")[-1] if "/" in name else name + + if basename in files_to_delete: + continue + if basename in allowed_filenames: + continue + # Check template patterns + matched = False + for prefix, suffix in template_suffixes: + if basename.startswith(prefix) and basename.endswith(suffix): + matched = True + break + if not matched: + unrecognized.append(basename) + + if unrecognized: + preview = ", ".join(unrecognized[:10]) + if len(unrecognized) > 10: + preview += f", ... ({len(unrecognized)} total)" + return {"error": f"Zip contains unrecognized files: {preview}", "cleaned_zip": None} + + # Rebuild the zip without the files to delete + out_buf = io.BytesIO() + with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as out_zf: + for info in zf.infolist(): + if info.is_dir(): + continue + basename = info.filename.split("/")[-1] if "/" in info.filename else info.filename + if basename in files_to_delete: + continue + out_zf.writestr(info, zf.read(info.filename)) + zf.close() + + return {"error": None, "cleaned_zip": out_buf.getvalue()} + + @plan_routes_bp.route("/plan/import", methods=["GET", "POST"]) @login_required def plan_import(): @@ -970,31 +1049,36 @@ def plan_import(): message = f"Zip file too large ({zip_size / 1024 / 1024:.1f} MB). Maximum is {max_zip_size // 1024 // 1024} MB." message_type = "error" else: - try: - user_id = str(current_user.id) - plan = PlanItem( - prompt=f"[Imported from {zip_file.filename}]", - state=PlanState.import_pending, - user_id=user_id, - parameters={ - "trigger_source": "frontend import", - "import_filename": zip_file.filename, - "pipeline_version": PIPELINE_VERSION, - }, - run_zip_snapshot=zip_data, - ) - db.session.add(plan) - db.session.commit() - logger.info( - "Plan import: created plan %s from %r (%s bytes) for user %s", - plan.id, zip_file.filename, zip_size, user_id, - ) - return redirect(url_for("plan_routes.plan", id=str(plan.id))) - except Exception as exc: - db.session.rollback() - logger.error("Plan import failed for %r: %s", zip_file.filename, exc) - message = "Import failed. Please try again." + result = _validate_and_clean_import_zip(zip_data) + if result["error"]: + message = result["error"] message_type = "error" + else: + try: + user_id = str(current_user.id) + plan = PlanItem( + prompt=f"[Imported from {zip_file.filename}]", + state=PlanState.import_pending, + user_id=user_id, + parameters={ + "trigger_source": "frontend import", + "import_filename": zip_file.filename, + "pipeline_version": PIPELINE_VERSION, + }, + run_zip_snapshot=result["cleaned_zip"], + ) + db.session.add(plan) + db.session.commit() + logger.info( + "Plan import: created plan %s from %r (%s bytes, cleaned %s bytes) for user %s", + plan.id, zip_file.filename, zip_size, len(result["cleaned_zip"]), user_id, + ) + return redirect(url_for("plan_routes.plan", id=str(plan.id))) + except Exception as exc: + db.session.rollback() + logger.error("Plan import failed for %r: %s", zip_file.filename, exc) + message = "Import failed. Please try again." + message_type = "error" return render_template("plan_import.html", message=message, message_type=message_type) From 39b42ff7e77ee320b69d3038cd96fc281da8edb2 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 11 Apr 2026 23:36:59 +0200 Subject: [PATCH 08/34] fix: skip unrecognized files in imported zip instead of rejecting macOS resource forks (._*) and other non-PlanExe files are silently dropped. Only FilenameEnum files are kept. Rejects only if zero recognized files remain. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/src/plan_routes.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 5721756c..15d77385 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -1007,22 +1007,25 @@ def _validate_and_clean_import_zip(zip_data: bytes) -> dict: if not matched: unrecognized.append(basename) - if unrecognized: - preview = ", ".join(unrecognized[:10]) - if len(unrecognized) > 10: - preview += f", ... ({len(unrecognized)} total)" - return {"error": f"Zip contains unrecognized files: {preview}", "cleaned_zip": None} - - # Rebuild the zip without the files to delete + # Rebuild the zip: keep only recognized FilenameEnum files, skip extras and unrecognized + skip_files = files_to_delete | set(unrecognized) out_buf = io.BytesIO() + kept_count = 0 with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as out_zf: for info in zf.infolist(): if info.is_dir(): continue basename = info.filename.split("/")[-1] if "/" in info.filename else info.filename - if basename in files_to_delete: + if basename in skip_files: continue out_zf.writestr(info, zf.read(info.filename)) + kept_count += 1 + + if unrecognized: + logger.info("Plan import: skipped %d unrecognized files", len(unrecognized)) + + if kept_count == 0: + return {"error": "Zip contains no recognized PlanExe files.", "cleaned_zip": None} zf.close() return {"error": None, "cleaned_zip": out_buf.getvalue()} From f3c5dadd8d6ab2e1138d8efcb019dcd78fef63c7 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sat, 11 Apr 2026 23:42:05 +0200 Subject: [PATCH 09/34] fix: make entire window a drop target for plan import Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/templates/plan_import.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend_multi_user/templates/plan_import.html b/frontend_multi_user/templates/plan_import.html index 53d7aefd..c4e6c8d0 100644 --- a/frontend_multi_user/templates/plan_import.html +++ b/frontend_multi_user/templates/plan_import.html @@ -139,16 +139,20 @@

Import Plan

} }); - dropzone.addEventListener('dragover', function(e) { + // Entire window is the drop target + document.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.classList.add('drag-over'); }); - dropzone.addEventListener('dragleave', function() { - dropzone.classList.remove('drag-over'); + document.addEventListener('dragleave', function(e) { + // Only remove highlight when leaving the window entirely + if (!e.relatedTarget && e.clientX === 0 && e.clientY === 0) { + dropzone.classList.remove('drag-over'); + } }); - dropzone.addEventListener('drop', function(e) { + document.addEventListener('drop', function(e) { e.preventDefault(); dropzone.classList.remove('drag-over'); var files = e.dataTransfer.files; @@ -157,10 +161,6 @@

Import Plan

showFile(files[0]); } }); - - // Prevent browser default drop behavior on the whole page - document.addEventListener('dragover', function(e) { e.preventDefault(); }); - document.addEventListener('drop', function(e) { e.preventDefault(); }); })(); {% endblock %} From 50e1020541c9505f6c6dc99e81b12944a6b75a94 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sun, 12 Apr 2026 17:06:36 +0200 Subject: [PATCH 10/34] refactor: use fetch() for plan import upload instead of form POST Eliminates "Confirm Form Resubmission" on page reload. The upload is now a JS fetch() to /plan/import/upload (JSON API), and the page navigates via window.location on success. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/src/plan_routes.py | 98 +++++++++---------- .../templates/plan_import.html | 73 ++++++++++---- 2 files changed, 103 insertions(+), 68 deletions(-) diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 15d77385..4faa4b12 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -1031,58 +1031,56 @@ def _validate_and_clean_import_zip(zip_data: bytes) -> dict: return {"error": None, "cleaned_zip": out_buf.getvalue()} -@plan_routes_bp.route("/plan/import", methods=["GET", "POST"]) +@plan_routes_bp.route("/plan/import", methods=["GET"]) @login_required def plan_import(): - message = None - message_type = None - if request.method == "POST": - zip_file = request.files.get("zip_file") - if zip_file is None or zip_file.filename == "": - message = "No file selected." - message_type = "error" - elif not zip_file.filename.endswith(".zip"): - message = "Please upload a .zip file." - message_type = "error" - else: - zip_data = zip_file.read() - zip_size = len(zip_data) - max_zip_size = 10 * 1024 * 1024 # 10 MB - if zip_size > max_zip_size: - message = f"Zip file too large ({zip_size / 1024 / 1024:.1f} MB). Maximum is {max_zip_size // 1024 // 1024} MB." - message_type = "error" - else: - result = _validate_and_clean_import_zip(zip_data) - if result["error"]: - message = result["error"] - message_type = "error" - else: - try: - user_id = str(current_user.id) - plan = PlanItem( - prompt=f"[Imported from {zip_file.filename}]", - state=PlanState.import_pending, - user_id=user_id, - parameters={ - "trigger_source": "frontend import", - "import_filename": zip_file.filename, - "pipeline_version": PIPELINE_VERSION, - }, - run_zip_snapshot=result["cleaned_zip"], - ) - db.session.add(plan) - db.session.commit() - logger.info( - "Plan import: created plan %s from %r (%s bytes, cleaned %s bytes) for user %s", - plan.id, zip_file.filename, zip_size, len(result["cleaned_zip"]), user_id, - ) - return redirect(url_for("plan_routes.plan", id=str(plan.id))) - except Exception as exc: - db.session.rollback() - logger.error("Plan import failed for %r: %s", zip_file.filename, exc) - message = "Import failed. Please try again." - message_type = "error" - return render_template("plan_import.html", message=message, message_type=message_type) + return render_template("plan_import.html") + + +@plan_routes_bp.route("/plan/import/upload", methods=["POST"]) +@login_required +def plan_import_upload(): + """JSON API for zip upload. Called via fetch() from the import page.""" + zip_file = request.files.get("zip_file") + if zip_file is None or zip_file.filename == "": + return jsonify({"error": "No file selected."}), 400 + if not zip_file.filename.endswith(".zip"): + return jsonify({"error": "Please upload a .zip file."}), 400 + + zip_data = zip_file.read() + zip_size = len(zip_data) + max_zip_size = 10 * 1024 * 1024 # 10 MB + if zip_size > max_zip_size: + return jsonify({"error": f"Zip file too large ({zip_size / 1024 / 1024:.1f} MB). Maximum is {max_zip_size // 1024 // 1024} MB."}), 400 + + result = _validate_and_clean_import_zip(zip_data) + if result["error"]: + return jsonify({"error": result["error"]}), 400 + + try: + user_id = str(current_user.id) + plan = PlanItem( + prompt=f"[Imported from {zip_file.filename}]", + state=PlanState.import_pending, + user_id=user_id, + parameters={ + "trigger_source": "frontend import", + "import_filename": zip_file.filename, + "pipeline_version": PIPELINE_VERSION, + }, + run_zip_snapshot=result["cleaned_zip"], + ) + db.session.add(plan) + db.session.commit() + logger.info( + "Plan import: created plan %s from %r (%s bytes, cleaned %s bytes) for user %s", + plan.id, zip_file.filename, zip_size, len(result["cleaned_zip"]), user_id, + ) + return jsonify({"plan_id": str(plan.id)}), 200 + except Exception as exc: + db.session.rollback() + logger.error("Plan import failed for %r: %s", zip_file.filename, exc) + return jsonify({"error": "Import failed. Please try again."}), 500 @plan_routes_bp.route("/plan/stop", methods=["POST"]) diff --git a/frontend_multi_user/templates/plan_import.html b/frontend_multi_user/templates/plan_import.html index c4e6c8d0..0657438f 100644 --- a/frontend_multi_user/templates/plan_import.html +++ b/frontend_multi_user/templates/plan_import.html @@ -65,6 +65,10 @@ .import-submit:hover { opacity: 0.9; } + .import-submit:disabled { + opacity: 0.5; + cursor: not-allowed; + } .import-back { display: inline-block; margin-bottom: 16px; @@ -97,24 +101,16 @@

Import Plan

Upload a plan zip file to import it.

-
- - - -
-
📦
-
Drop a .zip file here or click to browse
-
.zip files only, max 10 MB
-
-
- -
+ -{% if message %} -
- {{ message }} +
+
📦
+
Drop a .zip file here or click to browse
+
.zip files only, max 10 MB
+
-{% endif %} + +
{% endblock %} From ec926194840e480f0e4553e1187b4329113e5d65 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sun, 12 Apr 2026 22:47:24 +0200 Subject: [PATCH 11/34] feat: add model profile picker and auto-resume on import - Model profile selector appears after file is selected - Imported plan is created with state=pending and resume=True, so the worker picks it up immediately and resumes from the existing files in the zip Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend_multi_user/src/plan_routes.py | 9 +++++++-- frontend_multi_user/templates/plan_import.html | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend_multi_user/src/plan_routes.py b/frontend_multi_user/src/plan_routes.py index 4faa4b12..ce4e6588 100644 --- a/frontend_multi_user/src/plan_routes.py +++ b/frontend_multi_user/src/plan_routes.py @@ -1034,7 +1034,8 @@ def _validate_and_clean_import_zip(zip_data: bytes) -> dict: @plan_routes_bp.route("/plan/import", methods=["GET"]) @login_required def plan_import(): - return render_template("plan_import.html") + from src.app import _model_profile_options + return render_template("plan_import.html", model_profile_options=_model_profile_options()) @plan_routes_bp.route("/plan/import/upload", methods=["POST"]) @@ -1059,14 +1060,18 @@ def plan_import_upload(): try: user_id = str(current_user.id) + raw_profile = request.form.get("model_profile") + selected_model_profile = normalize_model_profile(raw_profile).value plan = PlanItem( prompt=f"[Imported from {zip_file.filename}]", - state=PlanState.import_pending, + state=PlanState.pending, user_id=user_id, parameters={ "trigger_source": "frontend import", "import_filename": zip_file.filename, "pipeline_version": PIPELINE_VERSION, + "model_profile": selected_model_profile, + "resume": True, }, run_zip_snapshot=result["cleaned_zip"], ) diff --git a/frontend_multi_user/templates/plan_import.html b/frontend_multi_user/templates/plan_import.html index 0657438f..4d34b256 100644 --- a/frontend_multi_user/templates/plan_import.html +++ b/frontend_multi_user/templates/plan_import.html @@ -109,7 +109,15 @@

Import Plan

.zip files only, max 10 MB
- + +