Skip to content
Merged
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
22 changes: 22 additions & 0 deletions code/backend/app/api/v1/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from starlette.responses import Response

from app.api.deps import get_file_storage_service
from app.handlers import results as results_handlers
Expand Down Expand Up @@ -108,6 +109,27 @@ def get_display_path(
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get display path: {str(e)}")


@router.get("/{task_id}/robot-files.zip")
def get_robot_files_zip(
task_id: str,
storage=Depends(get_file_storage_service),
):
"""Download available robot-related task files as a zip archive."""
try:
payload, included, missing = results_handlers.get_robot_files_zip(task_id, storage)
headers = {
"Content-Disposition": f'attachment; filename="{task_id}_robot_files.zip"',
"X-Included-Files": ",".join(included),
"X-Missing-Files": ",".join(missing),
"Access-Control-Expose-Headers": "Content-Disposition, X-Included-Files, X-Missing-Files",
}
return Response(content=payload, media_type="application/zip", headers=headers)
Comment on lines +121 to +127

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content-Disposition is constructed directly from task_id. If task_id can contain quotes/newlines or path traversal sequences, this can enable header injection and/or unintended file access. Recommend validating task_id (same policy as delete_task_results) and using a sanitized filename token for the header value.

Copilot uses AI. Check for mistakes.
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to build robot files zip: {str(e)}") from e

@router.delete("/{task_id}", response_model=DeleteTaskResultsResponse)
def delete_task_results(
task_id: str,
Expand Down
47 changes: 47 additions & 0 deletions code/backend/app/handlers/results.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Results business logic: task summary, list, and task artifact readers."""

import io
import json
import zipfile
from pathlib import Path
from typing import Any, List, Optional

Expand Down Expand Up @@ -165,6 +167,51 @@ def get_display_path(task_id: str, storage: FileStorageService) -> DisplayPathRe
waypoints=waypoints,
)


def get_robot_files_zip(task_id: str, storage: FileStorageService) -> tuple[bytes, list[str], list[str]]:
"""
Build a zip of task robot artifacts, including whatever files are available.

Returns:
(zip_bytes, included_filenames, missing_filenames)
Raises:
FileNotFoundError: if none of the target files are available.
"""
expected = ["robot_path.json", "prescription.geojson", "prescription_config.json"]
included: list[str] = []
missing: list[str] = []
buffer = io.BytesIO()

robot_path = storage.get_robot_path_path(task_id)
prescription_path = storage.get_prescription_path(task_id)
prescription_config = storage.read_prescription_config(task_id)
Comment on lines +171 to +187

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new handler reads files based on task_id via storage.get_*_path(...), but those getters don't apply the same path-traversal guard used in FileStorageService.delete_task_results (file_storage.py:499-505). Add equivalent validation (or a shared validate_task_id) before resolving paths to prevent task_id values like "../../..." from escaping results_dir.

Copilot uses AI. Check for mistakes.

with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
if robot_path:
zf.write(robot_path, arcname="robot_path.json")
included.append("robot_path.json")
else:
missing.append("robot_path.json")

if prescription_path:
zf.write(prescription_path, arcname="prescription.geojson")
included.append("prescription.geojson")
else:
missing.append("prescription.geojson")

if prescription_config is not None:
zf.writestr("prescription_config.json", json.dumps(prescription_config, ensure_ascii=False, indent=2))
included.append("prescription_config.json")
else:
missing.append("prescription_config.json")

if not included:
raise FileNotFoundError(
f"No downloadable robot files found for task {task_id}. Missing: {', '.join(expected)}"
)

return buffer.getvalue(), included, missing
Comment on lines +183 to +213

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_robot_files_zip builds the entire ZIP in memory (BytesIO + buffer.getvalue()), which scales linearly with artifact size and can cause high memory usage for large prescriptions/paths. Consider streaming the ZIP (e.g., StreamingResponse) or writing to a temporary file and returning a FileResponse to avoid holding the full archive in RAM.

Copilot uses AI. Check for mistakes.
Comment on lines +171 to +213

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ZIP download behavior isn’t covered by existing results handler tests (code/backend/tests/test_handlers_results.py doesn’t exercise get_robot_files_zip). Add unit tests for: (1) all files present, (2) some missing but at least one included, and (3) none present -> FileNotFoundError, and validate included/missing lists + ZIP contents.

Copilot uses AI. Check for mistakes.

def delete_task_results(task_id: str, storage: FileStorageService) -> DeleteTaskResultsResponse:
"""Delete all results for a task."""
try:
Expand Down
46 changes: 42 additions & 4 deletions code/frontend/src/components/FieldMapsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ const FieldMapsView: React.FC = () => {
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeletingTask, setIsDeletingTask] = useState(false);
const [isDownloadingRobotFiles, setIsDownloadingRobotFiles] = useState(false);
const [downloadMissingFiles, setDownloadMissingFiles] = useState<string[]>([]);
const [downloadError, setDownloadError] = useState<string | null>(null);

// Check internet connectivity
useEffect(() => {
Expand Down Expand Up @@ -297,6 +300,8 @@ const FieldMapsView: React.FC = () => {
setPathJobId(null);
setPathError(null);
setIsPathEditorOpen(false);
setDownloadMissingFiles([]);
setDownloadError(null);
}, [selectedMap?.id]);

const closeDeleteConfirm = () => {
Expand Down Expand Up @@ -448,6 +453,28 @@ const FieldMapsView: React.FC = () => {
}
};

const downloadRobotFilesZip = async (taskId: string) => {
setIsDownloadingRobotFiles(true);
setDownloadMissingFiles([]);
setDownloadError(null);
try {
const { blob, missing } = await apiService.downloadTaskRobotFilesZip(taskId);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${taskId}_robot_files.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
setDownloadMissingFiles(missing);
} catch (err) {
setDownloadError(err instanceof Error ? err.message : 'Failed to download robot files');
} finally {
setIsDownloadingRobotFiles(false);
}
};
Comment on lines +456 to +476

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The browser-download logic here duplicates PesticidePrescriptionsView (object URL + temporary anchor + cleanup). Extracting this into a shared utility would reduce copy/paste and help ensure any future fixes (e.g., cleanup timing) are applied consistently.

Copilot uses AI. Check for mistakes.

if (loading) {
return (
<div className="flex items-center justify-center h-64">
Expand Down Expand Up @@ -768,14 +795,15 @@ const FieldMapsView: React.FC = () => {
Modify Path
</button>
<button
onClick={() => downloadGeoJSON(selectedMap)}
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors duration-200 text-sm flex items-center space-x-2"
title="Download GeoJSON"
onClick={() => void downloadRobotFilesZip(selectedMap.id)}
disabled={isDownloadingRobotFiles}
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors duration-200 text-sm disabled:opacity-60 disabled:cursor-not-allowed flex items-center space-x-2"
title="Download robot files zip"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>Download</span>
<span>{isDownloadingRobotFiles ? 'Downloading robot files...' : 'Download Robot Files'}</span>
</button>
<button
onClick={openDeleteConfirm}
Expand All @@ -787,6 +815,16 @@ const FieldMapsView: React.FC = () => {
</div>
</div>
</div>
{downloadError && (
<div className="mx-5 mt-4 rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{downloadError}
</div>
)}
{!downloadError && downloadMissingFiles.length > 0 && (
<div className="mx-5 mt-4 rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100">
Downloaded available files. Missing: {downloadMissingFiles.join(', ')}.
</div>
)}

{/* Content Area */}
<div className="flex-1 p-5 overflow-y-auto">
Expand Down
60 changes: 54 additions & 6 deletions code/frontend/src/components/PesticidePrescriptionsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ const PesticidePrescriptionsView: React.FC = () => {
const [regenerateSubmitting, setRegenerateSubmitting] = useState(false);
const [regenerateError, setRegenerateError] = useState<string | null>(null);
const [regenerateDraft, setRegenerateDraft] = useState<RegenerateFormDraft>(() => configToRegenerateDraft(null));
const [isDownloadingRobotFiles, setIsDownloadingRobotFiles] = useState(false);
const [downloadMissingFiles, setDownloadMissingFiles] = useState<string[]>([]);
const [downloadError, setDownloadError] = useState<string | null>(null);

// Deterministic palette for visually distinguishing cluster polygons.
// (Only used when `properties.spray` is not set for a feature.)
Expand Down Expand Up @@ -163,6 +166,8 @@ const PesticidePrescriptionsView: React.FC = () => {
setSprayUpdates({});
setDetailConfig(null);
setThresholdsModalOpen(false);
setDownloadMissingFiles([]);
setDownloadError(null);
setDetailLoading(true);
try {
const [geojson, status, cfg] = await Promise.all([
Expand Down Expand Up @@ -205,8 +210,33 @@ const PesticidePrescriptionsView: React.FC = () => {
setThresholdsModalOpen(false);
setRegenerateModalOpen(false);
setRegenerateError(null);
setDownloadMissingFiles([]);
setDownloadError(null);
}, []);

const handleDownloadRobotFiles = useCallback(async () => {
if (!selectedTaskId) return;
setIsDownloadingRobotFiles(true);
setDownloadError(null);
setDownloadMissingFiles([]);
try {
const { blob, missing } = await apiService.downloadTaskRobotFilesZip(selectedTaskId);
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${selectedTaskId}_robot_files.zip`;
document.body.appendChild(anchor);
anchor.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor);
setDownloadMissingFiles(missing);
} catch (e) {
setDownloadError(e instanceof Error ? e.message : 'Failed to download robot files');
} finally {
setIsDownloadingRobotFiles(false);
}
Comment on lines +217 to +237

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The browser-download logic (create object URL, create , click, cleanup, error handling) is duplicated here and in FieldMapsView. Consider extracting a small shared helper (e.g., downloadBlobAsFile) to keep cleanup behavior consistent and reduce future maintenance overhead.

Copilot uses AI. Check for mistakes.
}, [selectedTaskId]);

const getFeatureId = (feature: PrescriptionFeature): string | null => {
if (feature.id != null) return String(feature.id);
if (feature.properties?.id != null) return String(feature.properties.id);
Expand Down Expand Up @@ -524,13 +554,21 @@ const PesticidePrescriptionsView: React.FC = () => {
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => void handleDownloadRobotFiles()}
disabled={isDownloadingRobotFiles}
className="px-3 py-2 text-sm bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{isDownloadingRobotFiles ? 'Downloading robot files…' : 'Download Robot Files'}
</button>
<button
type="button"
onClick={() => void openRegenerateModal()}
disabled={detailLoading || detailStatus?.status === 'processing'}
className="px-3 py-2 text-sm bg-dark-600 text-dark-100 border border-dark-500 rounded-lg hover:bg-dark-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
Regenerate prescription
Regenerate Prescription
</button>
<button type="button" onClick={closeDetail} className="text-dark-400 hover:text-dark-100 p-2" aria-label="Close">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand All @@ -550,6 +588,16 @@ const PesticidePrescriptionsView: React.FC = () => {
</p>
</div>
)}
{downloadError && (
<div className="px-6 py-3 bg-red-500/10 border-b border-red-500/30 text-red-300 text-sm">
{downloadError}
</div>
)}
{!downloadError && downloadMissingFiles.length > 0 && (
<div className="px-6 py-3 bg-yellow-500/10 border-b border-yellow-500/30 text-yellow-100 text-sm">
Downloaded available files. Missing: {downloadMissingFiles.join(', ')}.
</div>
)}
<div className="p-6 flex-1 overflow-y-auto">
{detailLoading ? (
<div className="flex justify-center py-12">
Expand Down Expand Up @@ -636,8 +684,8 @@ const PesticidePrescriptionsView: React.FC = () => {
<div>
<h4 className="text-lg font-medium text-primary-400 mb-2">Spray by cluster</h4>
<p className="text-dark-400 text-sm mb-4">
Set spray level per feature and click Save spray updates. Configure gallons-per-acre rates for each
level under Configure spray thresholds.
Set spray level per feature and click Save Spray Updates. Configure gallons-per-acre rates for each
level under Configure Spray Thresholds.
</p>
{totalGallons != null && (
<p className="text-dark-200 text-sm mb-3">
Expand Down Expand Up @@ -678,15 +726,15 @@ const PesticidePrescriptionsView: React.FC = () => {
onClick={openThresholdsModal}
className="px-4 py-2 bg-dark-600 text-dark-100 border border-dark-500 rounded-lg hover:bg-dark-500"
>
Configure spray thresholds
Configure Spray Thresholds
</button>
<button
type="button"
onClick={saveSprayUpdates}
disabled={savingSpray || Object.keys(sprayUpdates).length === 0}
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{savingSpray ? 'Saving…' : 'Save spray updates'}
{savingSpray ? 'Saving…' : 'Save Spray Updates'}
</button>
</div>
</div>
Expand All @@ -709,7 +757,7 @@ const PesticidePrescriptionsView: React.FC = () => {
aria-labelledby="regenerate-rx-title"
>
<h4 id="regenerate-rx-title" className="text-lg font-semibold text-primary-400 mb-2">
Regenerate prescription
Regenerate Prescription
</h4>
<p className="text-dark-400 text-sm mb-4">
These values are merged with global defaults for this task. Empty fields keep the current saved value.
Expand Down
23 changes: 23 additions & 0 deletions code/frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,29 @@ class ApiService {
return response.json();
}

/**
* Download available robot-related files for a task as a zip bundle.
*/
async downloadTaskRobotFilesZip(taskId: string): Promise<{ blob: Blob; missing: string[]; included: string[] }> {
const response = await fetch(`${this.baseUrl}/api/v1/results/${taskId}/robot-files.zip`);
if (!response.ok) {
const text = await response.text();
throw new Error(`Download robot files failed: ${response.status} ${text}`);
}
const parseHeaderList = (value: string | null): string[] => {
if (!value) return [];
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
};

const missing = parseHeaderList(response.headers.get('X-Missing-Files'));
const included = parseHeaderList(response.headers.get('X-Included-Files'));
const blob = await response.blob();
return { blob, missing, included };
}

/**
* Update prescription spray levels for features.
*/
Expand Down
Loading