From 8c8a81948eb4e35e45abf6ec3eeca15f0f5494b9 Mon Sep 17 00:00:00 2001
From: sallyom
Date: Tue, 9 Dec 2025 20:23:58 -0500
Subject: [PATCH 01/13] feat: Add file upload to sessions via content service
Add file upload functionality to sessions, allowing users to upload files
from local machine or URL to the workspace. Files are uploaded to the
file-uploads directory and accessible to Claude Code runners.
- Backend: Auto-spawn temp-content pod when service unavailable (202 Accepted)
- Operator: Support temp pods for Pending sessions, prevent PVC mount conflicts
- Frontend: Upload modal with local/URL tabs, integrated in File Explorer and Add Context modal
- Files uploaded to /workspace/file-uploads/ directory
Co-Authored-By: Claude Sonnet 4.5
Signed-off-by: sallyom
---
components/backend/handlers/sessions.go | 60 +++++-
.../[sessionName]/workspace/upload/route.ts | 164 +++++++++++++++
.../components/modals/add-context-modal.tsx | 29 ++-
.../components/modals/upload-file-modal.tsx | 195 ++++++++++++++++++
.../[name]/sessions/[sessionName]/page.tsx | 86 +++++++-
.../src/components/session/WorkspaceTab.tsx | 19 +-
.../operator/internal/handlers/sessions.go | 75 ++++++-
7 files changed, 603 insertions(+), 25 deletions(-)
create mode 100644 components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts
create mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/upload-file-modal.tsx
diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go
index 12c0cc73b..028ea34d8 100644
--- a/components/backend/handlers/sessions.go
+++ b/components/backend/handlers/sessions.go
@@ -2552,17 +2552,61 @@ func PutSessionWorkspaceFile(c *gin.Context) {
// Try temp service first (for completed sessions), then regular service
serviceName := fmt.Sprintf("temp-content-%s", session)
- k8sClt, _ := GetK8sClientsForRequest(c)
- if k8sClt == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
- c.Abort()
- return
- }
- if _, err := k8sClt.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil {
- // Temp service doesn't exist, use regular service
+ reqK8s, reqDyn := GetK8sClientsForRequest(c)
+ serviceFound := false
+
+ if reqK8s != nil {
+ if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil {
+ // Temp service doesn't exist, try regular service
+ serviceName = fmt.Sprintf("ambient-content-%s", session)
+ if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil {
+ // Neither service exists - need to spawn temp content pod
+ log.Printf("PutSessionWorkspaceFile: No content service found for session %s, requesting temp pod", session)
+ serviceFound = false
+ } else {
+ serviceFound = true
+ }
+ } else {
+ serviceFound = true
+ }
+ } else {
serviceName = fmt.Sprintf("ambient-content-%s", session)
}
+ // If no service exists, request temp content pod and return accepted status
+ if !serviceFound && reqDyn != nil {
+ gvr := GetAgenticSessionV1Alpha1Resource()
+ item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), session, v1.GetOptions{})
+ if err != nil {
+ if errors.IsNotFound(err) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"})
+ return
+ }
+
+ // Request temp content pod via annotation
+ annotations := item.GetAnnotations()
+ if annotations == nil {
+ annotations = make(map[string]string)
+ }
+ now := time.Now().UTC().Format(time.RFC3339)
+ annotations["ambient-code.io/temp-content-requested"] = "true"
+ annotations["ambient-code.io/temp-content-last-accessed"] = now
+ item.SetAnnotations(annotations)
+
+ if _, err := reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}); err != nil {
+ log.Printf("PutSessionWorkspaceFile: Failed to request temp pod: %v", err)
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Content service not available, please try again in a few seconds"})
+ return
+ }
+
+ log.Printf("PutSessionWorkspaceFile: Requested temp content pod for session %s", session)
+ c.JSON(http.StatusAccepted, gin.H{"message": "Content service starting, please retry upload in a few seconds"})
+ return
+ }
+
endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project)
log.Printf("PutSessionWorkspaceFile: using service %s for session %s", serviceName, session)
payload, _ := io.ReadAll(c.Request.Body)
diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts
new file mode 100644
index 000000000..06089a316
--- /dev/null
+++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/upload/route.ts
@@ -0,0 +1,164 @@
+import { buildForwardHeadersAsync } from '@/lib/auth';
+import { BACKEND_URL } from '@/lib/config';
+import { NextRequest } from 'next/server';
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ name: string; sessionName: string }> },
+) {
+ const { name, sessionName } = await params;
+ const headers = await buildForwardHeadersAsync(request);
+
+ try {
+ const formData = await request.formData();
+ const uploadType = formData.get('type') as string;
+
+ if (uploadType === 'local') {
+ // Handle local file upload
+ const file = formData.get('file') as File;
+ if (!file) {
+ return new Response(JSON.stringify({ error: 'No file provided' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ const filename = (formData.get('filename') as string) || file.name;
+ const fileBuffer = await file.arrayBuffer();
+
+ // Upload to workspace/file-uploads directory using the PUT endpoint
+ // Retry logic: if backend returns 202 (content service starting), retry up to 3 times
+ let resp: Response | null = null;
+ let retries = 0;
+ const maxRetries = 3;
+ const retryDelay = 2000; // 2 seconds
+
+ while (retries <= maxRetries) {
+ resp = await fetch(
+ `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/workspace/file-uploads/${encodeURIComponent(filename)}`,
+ {
+ method: 'PUT',
+ headers: {
+ ...headers,
+ 'Content-Type': file.type || 'application/octet-stream',
+ },
+ body: fileBuffer,
+ },
+ );
+
+ // If 202 Accepted (content service starting), wait and retry
+ if (resp.status === 202 && retries < maxRetries) {
+ retries++;
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
+ continue;
+ }
+
+ break;
+ }
+
+ if (!resp) {
+ return new Response(JSON.stringify({ error: 'Upload failed - no response from server' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ if (!resp.ok) {
+ const errorText = await resp.text();
+ return new Response(JSON.stringify({ error: 'Failed to upload file', details: errorText }), {
+ status: resp.status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ return new Response(JSON.stringify({ success: true, filename }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ } else if (uploadType === 'url') {
+ // Handle URL-based file upload
+ const fileUrl = formData.get('url') as string;
+ const filename = formData.get('filename') as string;
+
+ if (!fileUrl || !filename) {
+ return new Response(JSON.stringify({ error: 'URL and filename are required' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Download the file from URL
+ const fileResp = await fetch(fileUrl);
+ if (!fileResp.ok) {
+ return new Response(JSON.stringify({ error: 'Failed to download file from URL' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ const fileBuffer = await fileResp.arrayBuffer();
+ const contentType = fileResp.headers.get('content-type') || 'application/octet-stream';
+
+ // Upload to workspace/file-uploads directory using the PUT endpoint
+ // Retry logic: if backend returns 202 (content service starting), retry up to 3 times
+ let resp: Response | null = null;
+ let retries = 0;
+ const maxRetries = 3;
+ const retryDelay = 2000; // 2 seconds
+
+ while (retries <= maxRetries) {
+ resp = await fetch(
+ `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/workspace/file-uploads/${encodeURIComponent(filename)}`,
+ {
+ method: 'PUT',
+ headers: {
+ ...headers,
+ 'Content-Type': contentType,
+ },
+ body: fileBuffer,
+ },
+ );
+
+ // If 202 Accepted (content service starting), wait and retry
+ if (resp.status === 202 && retries < maxRetries) {
+ retries++;
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
+ continue;
+ }
+
+ break;
+ }
+
+ if (!resp) {
+ return new Response(JSON.stringify({ error: 'Upload failed - no response from server' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ if (!resp.ok) {
+ const errorText = await resp.text();
+ return new Response(JSON.stringify({ error: 'Failed to upload file', details: errorText }), {
+ status: resp.status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
+ return new Response(JSON.stringify({ success: true, filename }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ } else {
+ return new Response(JSON.stringify({ error: 'Invalid upload type' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ } catch (error) {
+ console.error('File upload error:', error);
+ return new Response(JSON.stringify({ error: 'Internal server error', details: String(error) }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+}
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx
index 609382ed5..b0fb5f4b7 100644
--- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/add-context-modal.tsx
@@ -1,17 +1,19 @@
"use client";
import { useState } from "react";
-import { Loader2, Info } from "lucide-react";
+import { Loader2, Info, Upload } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Separator } from "@/components/ui/separator";
type AddContextModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onAddRepository: (url: string, branch: string) => Promise;
+ onUploadFile?: () => void;
isLoading?: boolean;
};
@@ -19,6 +21,7 @@ export function AddContextModal({
open,
onOpenChange,
onAddRepository,
+ onUploadFile,
isLoading = false,
}: AddContextModalProps) {
const [contextUrl, setContextUrl] = useState("");
@@ -83,6 +86,30 @@ export function AddContextModal({
Leave empty to use the default branch
+
+ {onUploadFile && (
+ <>
+
+
+
+
+ Upload files directly to your workspace for use as context
+
+
+
+ >
+ )}
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/upload-file-modal.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/upload-file-modal.tsx
new file mode 100644
index 000000000..89d7a69f5
--- /dev/null
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/modals/upload-file-modal.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { useState, useRef } from "react";
+import { Loader2, Link, FileUp } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+type UploadFileModalProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onUploadFile: (source: {
+ type: "local" | "url";
+ file?: File;
+ url?: string;
+ filename?: string;
+ }) => Promise;
+ isLoading?: boolean;
+};
+
+export function UploadFileModal({
+ open,
+ onOpenChange,
+ onUploadFile,
+ isLoading = false,
+}: UploadFileModalProps) {
+ const [activeTab, setActiveTab] = useState<"local" | "url">("local");
+ const [fileUrl, setFileUrl] = useState("");
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [isStartingService, setIsStartingService] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const handleSubmit = async () => {
+ setIsStartingService(false);
+
+ if (activeTab === "local") {
+ if (!selectedFile) return;
+ try {
+ await onUploadFile({ type: "local", file: selectedFile });
+ } catch (error) {
+ // Check if error is about content service starting
+ if (error instanceof Error && error.message.includes("starting")) {
+ setIsStartingService(true);
+ }
+ throw error;
+ }
+ } else {
+ if (!fileUrl.trim()) return;
+
+ // Extract filename from URL
+ const urlParts = fileUrl.split("/");
+ const filename = urlParts[urlParts.length - 1] || "downloaded-file";
+
+ try {
+ await onUploadFile({ type: "url", url: fileUrl.trim(), filename });
+ } catch (error) {
+ // Check if error is about content service starting
+ if (error instanceof Error && error.message.includes("starting")) {
+ setIsStartingService(true);
+ }
+ throw error;
+ }
+ }
+
+ // Reset form on success
+ setFileUrl("");
+ setSelectedFile(null);
+ setIsStartingService(false);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ const handleCancel = () => {
+ setFileUrl("");
+ setSelectedFile(null);
+ setIsStartingService(false);
+ setActiveTab("local");
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ onOpenChange(false);
+ };
+
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ setSelectedFile(file);
+ }
+ };
+
+ const isSubmitDisabled = () => {
+ if (isLoading) return true;
+ if (activeTab === "local") return !selectedFile;
+ if (activeTab === "url") return !fileUrl.trim();
+ return true;
+ };
+
+ return (
+
+ );
+}
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx
index 7a681ca1f..e050bb81d 100644
--- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx
@@ -65,6 +65,7 @@ import { getPhaseColor } from "@/utils/session-helpers";
// Extracted components
import { AddContextModal } from "./components/modals/add-context-modal";
+import { UploadFileModal } from "./components/modals/upload-file-modal";
import { CustomWorkflowDialog } from "./components/modals/custom-workflow-dialog";
import { ManageRemoteDialog } from "./components/modals/manage-remote-dialog";
import { CommitChangesDialog } from "./components/modals/commit-changes-dialog";
@@ -117,6 +118,7 @@ export default function ProjectSessionDetailPage({
const [backHref, setBackHref] = useState(null);
const [openAccordionItems, setOpenAccordionItems] = useState(["workflows"]);
const [contextModalOpen, setContextModalOpen] = useState(false);
+ const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [repoChanging, setRepoChanging] = useState(false);
const [firstMessageLoaded, setFirstMessageLoaded] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@@ -255,6 +257,52 @@ export default function ProjectSessionDetailPage({
},
});
+ // File upload mutation
+ const uploadFileMutation = useMutation({
+ mutationFn: async (source: {
+ type: "local" | "url";
+ file?: File;
+ url?: string;
+ filename?: string;
+ }) => {
+ const formData = new FormData();
+ formData.append("type", source.type);
+
+ if (source.type === "local" && source.file) {
+ formData.append("file", source.file);
+ formData.append("filename", source.file.name);
+ } else if (source.type === "url" && source.url && source.filename) {
+ formData.append("url", source.url);
+ formData.append("filename", source.filename);
+ }
+
+ const response = await fetch(
+ `/api/projects/${projectName}/agentic-sessions/${sessionName}/workspace/upload`,
+ {
+ method: "POST",
+ body: formData,
+ },
+ );
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || "Upload failed");
+ }
+
+ return response.json();
+ },
+ onSuccess: async (data) => {
+ successToast(`File "${data.filename}" uploaded successfully`);
+ // Refresh workspace to show uploaded file
+ await refetchDirectoryFiles();
+ await refetchArtifactsFiles();
+ setUploadModalOpen(false);
+ },
+ onError: (error: Error) => {
+ errorToast(error.message || "Failed to upload file");
+ },
+ });
+
// Fetch OOTB workflows
const { data: ootbWorkflows = [] } = useOOTBWorkflows(projectName);
@@ -935,14 +983,26 @@ export default function ProjectSessionDetailPage({
) : (
-
+
+
+
+
)}
@@ -1240,9 +1300,19 @@ export default function ProjectSessionDetailPage({
await addRepoMutation.mutateAsync({ url, branch });
setContextModalOpen(false);
}}
+ onUploadFile={() => setUploadModalOpen(true)}
isLoading={addRepoMutation.isPending}
/>
+ {
+ await uploadFileMutation.mutateAsync(source);
+ }}
+ isLoading={uploadFileMutation.isPending}
+ />
+
void;
onSelect: (node: FileTreeNode) => void;
onToggle: (node: FileTreeNode) => void;
+ onUpload?: () => void;
k8sResources?: {
pvcName?: string;
pvcExists?: boolean;
@@ -26,7 +27,7 @@ export type WorkspaceTabProps = {
onRetrySpawn?: () => void;
};
-const WorkspaceTab: React.FC = ({ session, wsLoading, wsUnavailable, wsTree, wsSelectedPath, onRefresh, onSelect, onToggle, k8sResources, contentPodError, onRetrySpawn }) => {
+const WorkspaceTab: React.FC = ({ session, wsLoading, wsUnavailable, wsTree, wsSelectedPath, onRefresh, onSelect, onToggle, onUpload, k8sResources, contentPodError, onRetrySpawn }) => {
if (wsLoading) {
return (
@@ -90,9 +91,17 @@ const WorkspaceTab: React.FC
= ({ session, wsLoading, wsUnava
{wsTree.length} items
)}
-
+
+ {onUpload && (
+
+ )}
+
+
{wsTree.length === 0 ? (
diff --git a/components/operator/internal/handlers/sessions.go b/components/operator/internal/handlers/sessions.go
index 1059c807c..91a6ba55c 100644
--- a/components/operator/internal/handlers/sessions.go
+++ b/components/operator/internal/handlers/sessions.go
@@ -292,12 +292,15 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
}
// === TEMP CONTENT POD RECONCILIATION ===
- // Manage temporary content pods for workspace access on stopped sessions
+ // Manage temporary content pods for workspace access when runner is not active
tempContentRequested := annotations != nil && annotations[tempContentRequestedAnnotation] == "true"
tempPodName := fmt.Sprintf("temp-content-%s", name)
- // Only manage temp pods for stopped/completed/failed sessions
+ // Manage temp pods for:
+ // - Pending sessions (for pre-upload before runner starts)
+ // - Stopped/Completed/Failed sessions (for post-session workspace access)
+ // Do NOT create temp pods for Running/Creating sessions (they have ambient-content service)
if phase == "Stopped" || phase == "Completed" || phase == "Failed" {
if tempContentRequested {
// User wants workspace access - ensure temp pod exists
@@ -330,6 +333,33 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
return nil
}
+ // For Pending sessions: allow temp pod creation for file uploads, but don't return early
+ // This ensures Job creation can proceed when user starts the session
+ if phase == "Pending" {
+ if tempContentRequested {
+ // User wants to upload files - ensure temp pod exists
+ if err := reconcileTempContentPodWithPatch(sessionNamespace, name, tempPodName, currentObj, statusPatch); err != nil {
+ log.Printf("[TempPod] Failed to reconcile temp pod for Pending session: %v", err)
+ }
+ // Apply status changes but CONTINUE to allow Job creation logic below
+ if statusPatch.HasChanges() {
+ if err := statusPatch.Apply(); err != nil {
+ log.Printf("[TempPod] Warning: failed to apply status patch: %v", err)
+ }
+ }
+ // Do NOT return - continue to Job creation logic
+ } else {
+ // Temp pod not requested - delete if it exists
+ _, err := config.K8sClient.CoreV1().Pods(sessionNamespace).Get(context.TODO(), tempPodName, v1.GetOptions{})
+ if err == nil {
+ log.Printf("[TempPod] Deleting temp pod from Pending session: %s", tempPodName)
+ if err := config.K8sClient.CoreV1().Pods(sessionNamespace).Delete(context.TODO(), tempPodName, v1.DeleteOptions{}); err != nil && !errors.IsNotFound(err) {
+ log.Printf("[TempPod] Failed to delete temp pod: %v", err)
+ }
+ }
+ }
+ }
+
// === CONTINUE WITH PHASE-BASED RECONCILIATION ===
// Early exit: If desired-phase is "Stopped", do not recreate jobs or reconcile
@@ -772,6 +802,44 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
log.Printf("Langfuse disabled, skipping secret copy")
}
+ // CRITICAL: Delete temp content pod before creating Job to avoid PVC mount conflict
+ // The PVC is ReadWriteOnce, so only one pod can mount it at a time
+ tempPodName = fmt.Sprintf("temp-content-%s", name)
+ if _, err := config.K8sClient.CoreV1().Pods(sessionNamespace).Get(context.TODO(), tempPodName, v1.GetOptions{}); err == nil {
+ log.Printf("[PVCConflict] Deleting temp pod %s before creating Job (ReadWriteOnce PVC)", tempPodName)
+
+ // Force immediate termination with zero grace period
+ gracePeriod := int64(0)
+ deleteOptions := v1.DeleteOptions{
+ GracePeriodSeconds: &gracePeriod,
+ }
+ if err := config.K8sClient.CoreV1().Pods(sessionNamespace).Delete(context.TODO(), tempPodName, deleteOptions); err != nil && !errors.IsNotFound(err) {
+ log.Printf("[PVCConflict] Warning: failed to delete temp pod: %v", err)
+ }
+
+ // Wait for temp pod to fully terminate to prevent PVC mount conflicts
+ // This is critical because ReadWriteOnce PVCs cannot be mounted by multiple pods
+ // With gracePeriod=0, this should complete in 1-3 seconds
+ log.Printf("[PVCConflict] Waiting for temp pod %s to fully terminate...", tempPodName)
+ maxWaitSeconds := 10 // Reduced from 30 since we're force-deleting
+ for i := 0; i < maxWaitSeconds*4; i++ { // Poll 4x per second for faster detection
+ _, err := config.K8sClient.CoreV1().Pods(sessionNamespace).Get(context.TODO(), tempPodName, v1.GetOptions{})
+ if errors.IsNotFound(err) {
+ elapsed := float64(i) * 0.25
+ log.Printf("[PVCConflict] Temp pod fully terminated after %.2f seconds", elapsed)
+ break
+ }
+ if i == (maxWaitSeconds*4)-1 {
+ log.Printf("[PVCConflict] Warning: temp pod still exists after %d seconds, proceeding anyway", maxWaitSeconds)
+ }
+ time.Sleep(250 * time.Millisecond) // Poll every 250ms instead of 1s
+ }
+
+ // Clear temp pod annotations since we're starting the session
+ _ = clearAnnotation(sessionNamespace, name, tempContentRequestedAnnotation)
+ _ = clearAnnotation(sessionNamespace, name, tempContentLastAccessedAnnotation)
+ }
+
// Create a Kubernetes Job for this AgenticSession
jobName := fmt.Sprintf("%s-job", name)
@@ -2152,7 +2220,8 @@ func reconcileTempContentPodWithPatch(sessionNamespace, sessionName, tempPodName
}},
},
Spec: corev1.PodSpec{
- RestartPolicy: corev1.RestartPolicyNever,
+ RestartPolicy: corev1.RestartPolicyNever,
+ TerminationGracePeriodSeconds: int64Ptr(0), // Enable instant termination
Containers: []corev1.Container{{
Name: "content",
Image: appConfig.ContentServiceImage,
From 037e94ee9f61a7730d127674723fa455ebb7169f Mon Sep 17 00:00:00 2001
From: sallyom
Date: Tue, 9 Dec 2025 21:07:51 -0500
Subject: [PATCH 02/13] feat: Add File Uploads to directory selector in File
Explorer
Add "File Uploads" option to the directory selector dropdown so users can
navigate to the file-uploads directory and see their uploaded files.
- Add file-uploads option to directoryOptions array
- Add CloudUpload icon for file-uploads type in dropdown
- Files will appear even if directory is empty (shows empty state)
Co-Authored-By: Claude Sonnet 4.5
Signed-off-by: sallyom
---
.../app/projects/[name]/sessions/[sessionName]/lib/types.ts | 2 +-
.../src/app/projects/[name]/sessions/[sessionName]/page.tsx | 4 ++++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/lib/types.ts b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/lib/types.ts
index 6e6c7a07e..377e18773 100644
--- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/lib/types.ts
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/lib/types.ts
@@ -32,7 +32,7 @@ export type GitStatus = {
};
export type DirectoryOption = {
- type: 'artifacts' | 'repo' | 'workflow';
+ type: 'artifacts' | 'repo' | 'workflow' | 'file-uploads';
name: string;
path: string;
};
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx
index e050bb81d..df3beb97c 100644
--- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx
@@ -428,6 +428,7 @@ export default function ProjectSessionDetailPage({
const directoryOptions = useMemo(() => {
const options: DirectoryOption[] = [
{ type: "artifacts", name: "Shared Artifacts", path: "artifacts" },
+ { type: "file-uploads", name: "File Uploads", path: "file-uploads" },
];
if (session?.spec?.repos) {
@@ -903,6 +904,9 @@ export default function ProjectSessionDetailPage({
{opt.type === "artifacts" && (
)}
+ {opt.type === "file-uploads" && (
+
+ )}
{opt.type === "repo" && (
)}
From fbfbbced762bfabb889312c3a3a516348763a03b Mon Sep 17 00:00:00 2001
From: sallyom
Date: Tue, 9 Dec 2025 21:32:45 -0500
Subject: [PATCH 03/13] feat: Add file upload and management to sessions
This commit adds comprehensive file upload and management capabilities:
## Upload Functionality
- Users can upload files from local machine or URL via upload modal
- Files stored in workspace/file-uploads/ directory
- Modal accessible from Add Context window and File Explorer
- Auto-retry logic when content service is starting (202 Accepted)
- Temp pod spawning for Pending sessions to enable pre-upload
## Context Visualization
- Context accordion shows combined repos + uploaded files
- Badge displays total context count (repos + files)
- Repos: GitBranch icon with URL
- Files: CloudUpload icon with size
- Files can be removed via X button
## File Explorer Integration
- File Uploads directory visible in directory selector dropdown
- Always shown even when empty
- Same browsing/viewing as other directories
## Backend Architecture
- Added DeleteSessionWorkspaceFile handler in sessions.go
- Added ContentDelete handler in new content.go for content service
- DELETE route: /workspace/*path
- Proper JSON responses for frontend compatibility
## Frontend Architecture
- New workspace API route with GET/PUT/DELETE support
- Upload modal with local file and URL tabs
- Remove file mutation with proper refetch logic
- TypeScript types updated for file-uploads directory
## Operator Changes
- Support for temp pods in Pending sessions
- Critical fix: Delete temp pod before Job to avoid PVC mount conflicts
- TerminationGracePeriodSeconds=0 for instant cleanup
Co-authored-by: Claude Code Runner
Signed-off-by: Sally O'Malley
Co-Authored-By: Claude Sonnet 4.5
---
components/backend/handlers/content.go | 39 +++++++++
components/backend/handlers/sessions.go | 85 +++++++++++++++++++
components/backend/routes.go | 2 +
.../workspace/[...path]/route.ts | 15 ++++
.../accordions/repositories-accordion.tsx | 82 +++++++++++++++---
.../[name]/sessions/[sessionName]/page.tsx | 46 ++++++++++
6 files changed, 258 insertions(+), 11 deletions(-)
diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go
index 4d2c861b5..9fc70d0f4 100644
--- a/components/backend/handlers/content.go
+++ b/components/backend/handlers/content.go
@@ -837,3 +837,42 @@ func ContentGitListBranches(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"branches": branches})
}
+
+// ContentDelete handles DELETE /content/delete when running in CONTENT_SERVICE_MODE
+func ContentDelete(c *gin.Context) {
+ var req struct {
+ Path string `json:"path"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ log.Printf("ContentDelete: bind JSON failed: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ log.Printf("ContentDelete: path=%q StateBaseDir=%q", req.Path, StateBaseDir)
+
+ path := filepath.Clean("/" + strings.TrimSpace(req.Path))
+ if path == "/" || strings.Contains(path, "..") {
+ log.Printf("ContentDelete: invalid path rejected: path=%q", path)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
+ return
+ }
+ abs := filepath.Join(StateBaseDir, path)
+ log.Printf("ContentDelete: absolute path=%q", abs)
+
+ // Check if file exists
+ if _, err := os.Stat(abs); os.IsNotExist(err) {
+ log.Printf("ContentDelete: file not found: %q", abs)
+ c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
+ return
+ }
+
+ // Delete the file
+ if err := os.Remove(abs); err != nil {
+ log.Printf("ContentDelete: delete failed for %q: %v", abs, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
+ return
+ }
+
+ log.Printf("ContentDelete: successfully deleted %q", abs)
+ c.JSON(http.StatusOK, gin.H{"message": "file deleted successfully"})
+}
diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go
index 028ea34d8..348d1e376 100644
--- a/components/backend/handlers/sessions.go
+++ b/components/backend/handlers/sessions.go
@@ -2632,6 +2632,91 @@ func PutSessionWorkspaceFile(c *gin.Context) {
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), rb)
}
+// DeleteSessionWorkspaceFile deletes a file via content service.
+func DeleteSessionWorkspaceFile(c *gin.Context) {
+ // Get project from context (set by middleware) or param
+ project := c.GetString("project")
+ if project == "" {
+ project = c.Param("projectName")
+ }
+ session := c.Param("sessionName")
+
+ if project == "" {
+ log.Printf("DeleteSessionWorkspaceFile: project is empty, session=%s", session)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"})
+ return
+ }
+ sub := strings.TrimPrefix(c.Param("path"), "/")
+ absPath := "/sessions/" + session + "/workspace/" + sub
+ token := c.GetHeader("Authorization")
+ if strings.TrimSpace(token) == "" {
+ token = c.GetHeader("X-Forwarded-Access-Token")
+ }
+
+ // Try temp service first, then regular service
+ serviceName := fmt.Sprintf("temp-content-%s", session)
+ reqK8s, _ := GetK8sClientsForRequest(c)
+ serviceFound := false
+
+ if reqK8s != nil {
+ if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil {
+ // Temp service doesn't exist, try regular service
+ serviceName = fmt.Sprintf("ambient-content-%s", session)
+ if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil {
+ log.Printf("DeleteSessionWorkspaceFile: No content service found for session %s", session)
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Content service not available"})
+ return
+ } else {
+ serviceFound = true
+ }
+ } else {
+ serviceFound = true
+ }
+ } else {
+ serviceName = fmt.Sprintf("ambient-content-%s", session)
+ }
+
+ if !serviceFound {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Content service not available"})
+ return
+ }
+
+ endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project)
+ log.Printf("DeleteSessionWorkspaceFile: using service %s for session %s, path=%s", serviceName, session, absPath)
+
+ // Use DELETE request with path in body
+ wreq := struct {
+ Path string `json:"path"`
+ }{Path: absPath}
+ b, _ := json.Marshal(wreq)
+ req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodDelete, endpoint+"/content/delete", strings.NewReader(string(b)))
+ if strings.TrimSpace(token) != "" {
+ req.Header.Set("Authorization", token)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ client := &http.Client{Timeout: 4 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
+ return
+ }
+ defer resp.Body.Close()
+
+ // Always return JSON for consistency with frontend expectations
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"})
+ } else {
+ rb, _ := io.ReadAll(resp.Body)
+ // Try to parse error from content service, otherwise use generic message
+ var errResp map[string]interface{}
+ if err := json.Unmarshal(rb, &errResp); err == nil {
+ c.JSON(resp.StatusCode, errResp)
+ } else {
+ c.JSON(resp.StatusCode, gin.H{"error": "Failed to delete file"})
+ }
+ }
+}
+
// PushSessionRepo proxies a push request for a given session repo to the per-job content service.
// POST /api/projects/:projectName/agentic-sessions/:sessionName/github/push
// Body: { repoIndex: number, commitMessage?: string, branch?: string }
diff --git a/components/backend/routes.go b/components/backend/routes.go
index 74d4412f9..a76273fd1 100644
--- a/components/backend/routes.go
+++ b/components/backend/routes.go
@@ -11,6 +11,7 @@ func registerContentRoutes(r *gin.Engine) {
r.POST("/content/write", handlers.ContentWrite)
r.GET("/content/file", handlers.ContentRead)
r.GET("/content/list", handlers.ContentList)
+ r.DELETE("/content/delete", handlers.ContentDelete)
r.POST("/content/github/push", handlers.ContentGitPush)
r.POST("/content/github/abandon", handlers.ContentGitAbandon)
r.GET("/content/github/diff", handlers.ContentGitDiff)
@@ -60,6 +61,7 @@ func registerRoutes(r *gin.Engine) {
projectGroup.GET("/agentic-sessions/:sessionName/workspace", handlers.ListSessionWorkspace)
projectGroup.GET("/agentic-sessions/:sessionName/workspace/*path", handlers.GetSessionWorkspaceFile)
projectGroup.PUT("/agentic-sessions/:sessionName/workspace/*path", handlers.PutSessionWorkspaceFile)
+ projectGroup.DELETE("/agentic-sessions/:sessionName/workspace/*path", handlers.DeleteSessionWorkspaceFile)
projectGroup.POST("/agentic-sessions/:sessionName/github/push", handlers.PushSessionRepo)
projectGroup.POST("/agentic-sessions/:sessionName/github/abandon", handlers.AbandonSessionRepo)
projectGroup.GET("/agentic-sessions/:sessionName/github/diff", handlers.DiffSessionRepo)
diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts
index 2d9737965..2ea4215c4 100644
--- a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts
+++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workspace/[...path]/route.ts
@@ -33,4 +33,19 @@ export async function PUT(
return new Response(respBody, { status: resp.status, headers: { 'Content-Type': 'application/json' } })
}
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ name: string; sessionName: string; path: string[] }> },
+) {
+ const { name, sessionName, path } = await params
+ const headers = await buildForwardHeadersAsync(request)
+ const rel = path.join('/')
+ const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/workspace/${encodeURIComponent(rel)}`, {
+ method: 'DELETE',
+ headers,
+ })
+ const respBody = await resp.text()
+ return new Response(respBody, { status: resp.status, headers: { 'Content-Type': 'application/json' } })
+}
+
diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx
index 192933d1e..5724a08a7 100644
--- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx
+++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/repositories-accordion.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import { GitBranch, X, Link, Loader2 } from "lucide-react";
+import { GitBranch, X, Link, Loader2, CloudUpload } from "lucide-react";
import { AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -11,20 +11,33 @@ type Repository = {
branch?: string;
};
+type UploadedFile = {
+ name: string;
+ path: string;
+ size?: number;
+};
+
type RepositoriesAccordionProps = {
repositories?: Repository[];
+ uploadedFiles?: UploadedFile[];
onAddRepository: () => void;
onRemoveRepository: (repoName: string) => void;
+ onRemoveFile?: (fileName: string) => void;
};
export function RepositoriesAccordion({
repositories = [],
+ uploadedFiles = [],
onAddRepository,
onRemoveRepository,
+ onRemoveFile,
}: RepositoriesAccordionProps) {
const [removingRepo, setRemovingRepo] = useState(null);
+ const [removingFile, setRemovingFile] = useState(null);
+
+ const totalContextItems = repositories.length + uploadedFiles.length;
- const handleRemove = async (repoName: string) => {
+ const handleRemoveRepo = async (repoName: string) => {
if (confirm(`Remove repository ${repoName}?`)) {
setRemovingRepo(repoName);
try {
@@ -35,15 +48,27 @@ export function RepositoriesAccordion({
}
};
+ const handleRemoveFile = async (fileName: string) => {
+ if (!onRemoveFile) return;
+ if (confirm(`Remove file ${fileName}?`)) {
+ setRemovingFile(fileName);
+ try {
+ await onRemoveFile(fileName);
+ } finally {
+ setRemovingFile(null);
+ }
+ }
+ };
+
return (
Context
- {repositories.length > 0 && (
+ {totalContextItems > 0 && (
- {repositories.length}
+ {totalContextItems}
)}
@@ -54,8 +79,8 @@ export function RepositoriesAccordion({
Add additional context to improve AI responses.
- {/* Repository List */}
- {repositories.length === 0 ? (
+ {/* Context Items List (Repos + Uploaded Files) */}
+ {totalContextItems === 0 ? (
@@ -68,22 +93,23 @@ export function RepositoriesAccordion({
) : (
+ {/* Repositories */}
{repositories.map((repo, idx) => {
const repoName = repo.url.split('/').pop()?.replace('.git', '') || `repo-${idx}`;
const isRemoving = removingRepo === repoName;
-
+
return (
-
+
-
);
})}
+
+ {/* Uploaded Files */}
+ {uploadedFiles.map((file, idx) => {
+ const isRemoving = removingFile === file.name;
+ const fileSizeKB = file.size ? (file.size / 1024).toFixed(1) : null;
+
+ return (
+
+
+
+
{file.name}
+ {fileSizeKB && (
+
{fileSizeKB} KB
+ )}
+
+ {onRemoveFile && (
+
+ )}
+
+ );
+ })}
+