Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions database_api/model_planitem.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class PlanState(enum.Enum):
completed = 3
failed = 4
stopped = 5
import_pending = 6


class PlanItem(db.Model):
Expand Down
2 changes: 1 addition & 1 deletion frontend_multi_user/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 6 additions & 5 deletions frontend_multi_user/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
134 changes: 134 additions & 0 deletions frontend_multi_user/src/plan_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,140 @@ 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)

# 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 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()}


@plan_routes_bp.route("/plan/import", methods=["GET"])
@login_required
def plan_import():
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"])
@login_required
def plan_stop():
Expand Down
203 changes: 203 additions & 0 deletions frontend_multi_user/templates/plan_import.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}Import Plan - PlanExe{% endblock %}
{% block head %}
<style>
.import-title {
margin: 0 0 8px;
font-size: 1.2rem;
font-weight: 700;
}
.import-subtitle {
margin: 0 0 20px;
font-size: 0.82rem;
color: var(--color-text-secondary);
}
.import-dropzone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-card-bg);
padding: 48px 24px;
max-width: 500px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.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-secondary);
}
.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;
color: #fff;
background: var(--color-primary, #0066cc);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
max-width: 500px;
width: 100%;
}
.import-submit:hover {
opacity: 0.9;
}
.import-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.import-back {
display: inline-block;
margin-bottom: 16px;
font-size: 0.82rem;
color: var(--color-primary, #0066cc);
text-decoration: none;
}
.import-message {
margin-top: 16px;
padding: 10px 14px;
border-radius: var(--radius-lg);
font-size: 0.82rem;
max-width: 500px;
}
.import-message-success {
background: #e6f4ea;
color: #1a7f37;
border: 1px solid #a3d9a5;
}
.import-message-error {
background: #fdecea;
color: #c0392b;
border: 1px solid #e6b0aa;
}
</style>
{% endblock %}

{% block content %}
<a href="/plan" class="import-back">&larr; Back to Plans</a>
<h1 class="import-title">Import Plan</h1>
<p class="import-subtitle">Upload a plan zip file to import it.</p>

<input type="file" id="zip-file" accept=".zip" style="display:none">

<div class="import-dropzone" id="dropzone">
<div class="import-dropzone-icon">&#128230;</div>
<div class="import-dropzone-text">Drop a .zip file here or click to browse</div>
<div class="import-dropzone-hint">.zip files only, max 10 MB</div>
<div class="import-dropzone-filename" id="filename-display"></div>
</div>
<button class="import-submit" id="submit-btn" type="button">Upload</button>
<div id="message-area"></div>

<script>
(function() {
var dropzone = document.getElementById('dropzone');
var fileInput = document.getElementById('zip-file');
var filenameDisplay = document.getElementById('filename-display');
var submitBtn = document.getElementById('submit-btn');
var messageArea = document.getElementById('message-area');
var selectedFile = null;

function showFile(file) {
selectedFile = file;
filenameDisplay.textContent = file.name + ' (' + (file.size / 1024).toFixed(0) + ' KB)';
filenameDisplay.style.display = 'block';
submitBtn.style.display = 'block';
messageArea.innerHTML = '';
}

function showMessage(text, type) {
messageArea.innerHTML = '<div class="import-message import-message-' + type + '">' + text + '</div>';
}

dropzone.addEventListener('click', function() {
fileInput.click();
});

fileInput.addEventListener('change', function() {
if (fileInput.files.length > 0) {
showFile(fileInput.files[0]);
}
});

document.addEventListener('dragover', function(e) {
e.preventDefault();
dropzone.classList.add('drag-over');
});

document.addEventListener('dragleave', function(e) {
if (!e.relatedTarget && e.clientX === 0 && e.clientY === 0) {
dropzone.classList.remove('drag-over');
}
});

document.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.classList.remove('drag-over');
var files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
showFile(files[0]);
}
});

submitBtn.addEventListener('click', function() {
if (!selectedFile) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Uploading...';
messageArea.innerHTML = '';

var formData = new FormData();
formData.append('zip_file', selectedFile);
formData.append('csrf_token', '{{ csrf_token() }}');

fetch('/plan/import/upload', {
method: 'POST',
body: formData,
})
.then(function(response) {
return response.json().then(function(data) {
return { ok: response.ok, data: data };
});
})
.then(function(result) {
if (result.ok && result.data.plan_id) {
window.location.href = '/plan?id=' + result.data.plan_id;
} else {
showMessage(result.data.error || 'Upload failed.', 'error');
submitBtn.disabled = false;
submitBtn.textContent = 'Upload';
}
})
.catch(function(err) {
showMessage('Upload failed: ' + err.message, 'error');
submitBtn.disabled = false;
submitBtn.textContent = 'Upload';
});
});
})();
</script>
{% endblock %}
Loading
Loading