This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new fa5fcbb60b feat: Make Python Virtual Environment Persistent: Add 
Environments to Left Panel  (#5577)
fa5fcbb60b is described below

commit fa5fcbb60b6f0a305a21635e2560ca0b04b823e2
Author: Sarah Asad <[email protected]>
AuthorDate: Fri Jun 12 13:38:25 2026 -0700

    feat: Make Python Virtual Environment Persistent: Add Environments to Left 
Panel  (#5577)
    
    <!--
    Thanks for sending a pull request (PR)! Here are some tips for you:
    1. If this is your first time, please read our contributor guidelines:
    [Contributing to
    Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md)
      2. Ensure you have added or run the appropriate tests for your PR
      3. If the PR is work in progress, mark it a draft on GitHub.
      4. Please write your PR title to summarize what this PR proposes, we
        are following Conventional Commits style for PR titles as well.
      5. Be sure to keep the PR description updated to reflect all changes.
    -->
    
    ### What changes were proposed in this PR?
    <!--
    Please clarify what changes you are proposing. The purpose of this
    section
    is to outline the changes. Here are some tips for you:
      1. If you propose a new API, clarify the use case for a new API.
      2. If you fix a bug, you can clarify why it is a bug.
      3. If it is a refactoring, clarify what has been changed.
      3. It would be helpful to include a before-and-after comparison using
         screenshots or GIFs.
      4. Please consider writing useful notes for better and faster reviews.
    -->
    
    This PR introduces persistent Python Virtual Environments (PVEs) by
    moving them out of the Computing Unit (CU) lifecycle and storing them in
    the database.
    
    Previously, PVEs were managed through Computing Units and existed only
    within the CU they were created in. As a result, PVEs were lost when the
    corresponding CU was terminated. This PR adds a new
    `virtual_environments` table to persist PVE configurations and
    introduces a dedicated dashboard interface for managing them.
    
    Users can now create, view, update, and delete their own Python virtual
    environments through a new "Environments" page in the dashboard sidebar.
    PVE definitions are stored as user-owned resources in the database and
    can be managed independently of Computing Units.
    
    <img width="1689" height="652" alt="Screenshot 2026-06-08 at 6 39 55 PM"
    
src="https://github.com/user-attachments/assets/82711baf-b1ce-4cc6-9e84-a29a230ddc3a";
    />
    
    <img width="1461" height="500" alt="Screenshot 2026-06-08 at 6 40 19 PM"
    
src="https://github.com/user-attachments/assets/5bbbc360-0adf-401b-8ae8-6d9597d486c2";
    />
    
    Note: This PR only introduces persistence for PVE metadata and
    configuration. Creating, updating, and deleting a PVE in this PR only
    affects the corresponding database records. The execution-time behavior
    of materializing and using these virtual environments inside a Computing
    Unit is not part of this change and will be introduced in a future PR.
    
    K8s configurations for this feature will be added in a future PR.
    
    ### Any related issues, documentation, discussions?
    <!--
    Please use this section to link other resources if not mentioned
    already.
    1. If this PR fixes an issue, please include `Fixes #1234`, `Resolves
    #1234`
    or `Closes #1234`. If it is only related, simply mention the issue
    number.
      2. If there is design documentation, please add the link.
      3. If there is a discussion in the mailing list, please add the link.
    -->
    
    Related discussions and issues: #5360, #5361.
    
    ### How was this PR tested?
    <!--
    If tests were added, say they were added here. Or simply mention that if
    the PR
    is tested with existing test cases. Make sure to include/update test
    cases that
    check the changes thoroughly including negative and positive cases if
    possible.
    If it was tested in a way different from regular unit tests, please
    clarify how
    you tested step by step, ideally copy and paste-able, so that other
    reviewers can
    test and check, and descendants can verify in the future. If tests were
    not added,
    please describe why they were not added and/or why it was difficult to
    add.
    -->
    
    Tested manually and tests added to PveResourceSpec.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    <!--
    If generative AI tooling has been used in the process of authoring this
    PR,
    please include the phrase: 'Generated-by: ' followed by the name of the
    tool
    and its version. If no, write 'No'.
    Please refer to the [ASF Generative Tooling
    Guidance](https://www.apache.org/legal/generative-tooling.html) for
    details.
    -->
    
    Co-authored using: Claude Code
---
 .../service/resource/AccessControlResource.scala   |  12 +-
 .../apache/texera/AccessControlResourceSpec.scala  |   7 +
 .../pythonvirtualenvironment/PveManager.scala      |  75 +++++++
 .../pythonvirtualenvironment/PveResource.scala     | 110 +++++++++-
 .../pythonvirtualenvironment/PveResourceSpec.scala | 141 +++++++++++-
 frontend/src/app/app-routing.constant.ts           |   1 +
 frontend/src/app/app-routing.module.ts             |   5 +
 frontend/src/app/app.module.ts                     |   2 +
 .../dashboard/component/dashboard.component.html   |  11 +
 .../component/dashboard.component.spec.ts          |   4 +-
 .../app/dashboard/component/dashboard.component.ts |   2 +
 .../user/user-venv/user-venv.component.html        | 183 ++++++++++++++++
 .../user/user-venv/user-venv.component.scss        |  99 +++++++++
 .../user/user-venv/user-venv.component.ts          | 243 +++++++++++++++++++++
 .../virtual-environment.service.spec.ts            |  84 +++++++
 .../virtual-environment.service.ts                 |  22 ++
 sql/changelog.xml                                  |   4 +
 sql/texera_ddl.sql                                 |  12 +
 sql/updates/24.sql                                 |  39 ++++
 19 files changed, 1048 insertions(+), 8 deletions(-)

diff --git 
a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
 
b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
index 0c90a6ce31..96b2d52624 100644
--- 
a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
+++ 
b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala
@@ -23,7 +23,7 @@ import com.typesafe.scalalogging.LazyLogging
 import jakarta.annotation.security.{PermitAll, RolesAllowed}
 import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
 import jakarta.ws.rs.core._
-import jakarta.ws.rs.{Consumes, DELETE, GET, POST, Path, Produces}
+import jakarta.ws.rs.{Consumes, DELETE, GET, POST, PUT, Path, Produces}
 import org.apache.texera.auth.JwtParser.parseToken
 import org.apache.texera.auth.SessionUser
 import org.apache.texera.auth.util.{ComputingUnitAccess, HeaderField}
@@ -233,6 +233,16 @@ class AccessControlResource extends LazyLogging {
     AccessControlResource.authorize(uriInfo, headers, 
Option(body).map(_.trim).filter(_.nonEmpty))
   }
 
+  @PUT
+  @Path("/{path:.*}")
+  def authorizePut(
+      @Context uriInfo: UriInfo,
+      @Context headers: HttpHeaders,
+      body: String
+  ): Response = {
+    AccessControlResource.authorize(uriInfo, headers, 
Option(body).map(_.trim).filter(_.nonEmpty))
+  }
+
   @DELETE
   @Path("/{path:.*}")
   def authorizeDelete(
diff --git 
a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
 
b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
index 75f3bacb10..3dfe81d89d 100644
--- 
a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
+++ 
b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala
@@ -291,4 +291,11 @@ class AccessControlResourceSpec
 
     response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
   }
+
+  it should "return OK for a PUT request when user has access" in {
+    val (uri, headers) = mockRequest("/pve/system", 
Some(testCU.getCuid.toString))
+    val response = new AccessControlResource().authorizePut(uri, headers, 
"""{"name":"env"}""")
+
+    response.getStatus shouldBe Response.Status.OK.getStatusCode
+  }
 }
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
index 2256798030..c82d252e43 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
@@ -26,6 +26,10 @@ import scala.jdk.CollectionConverters._
 import scala.sys.process._
 import java.util.Comparator
 import org.apache.texera.amber.config.PythonUtils
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.dao.jooq.generated.tables.daos.VirtualEnvironmentsDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.VirtualEnvironments
+import org.jooq.JSONB
 
 /**
   * PveManager is responsible for managing Python Virtual Environments (PVEs)
@@ -47,10 +51,15 @@ object PveManager {
       userPackages: Seq[String]
   )
 
+  case class StoredPve(veid: Int, name: String, packagesJson: String)
+
   private val VenvRoot: Path = Paths.get("/tmp/texera-pve/venvs")
 
   private val SafePveName = "^[A-Za-z0-9._-]+$".r
 
+  def isValidPveName(name: String): Boolean =
+    name != null && name.length <= 128 && 
SafePveName.pattern.matcher(name).matches()
+
   private def cuidDir(cuid: Int, pveName: String): Path = {
     VenvRoot.resolve(cuid.toString).resolve(pveName)
   }
@@ -213,6 +222,72 @@ object PveManager {
     queue.put(s"[PVE] Created new environment for cuid = $cuid")
   }
 
+  // Returns every PVE row belonging to the given user.
+  def listPvesForUser(uid: Int): List[StoredPve] = {
+    import org.apache.texera.dao.jooq.generated.Tables.VIRTUAL_ENVIRONMENTS
+    SqlServer
+      .getInstance()
+      .createDSLContext()
+      .selectFrom(VIRTUAL_ENVIRONMENTS)
+      .where(VIRTUAL_ENVIRONMENTS.UID.eq(uid))
+      .fetchInto(classOf[VirtualEnvironments])
+      .asScala
+      .map { row =>
+        val pkgsJson = Option(row.getPackages).map(_.data).getOrElse("{}")
+        StoredPve(row.getVeid, row.getName, pkgsJson)
+      }
+      .toList
+  }
+
+  // Deletes a PVE row owned by `uid`. Returns true if a row was deleted, 
false if no
+  // matching row was found (either the veid doesn't exist or it belongs to 
another user).
+  def deletePveFromDb(veid: Int, uid: Int): Boolean = {
+    import org.apache.texera.dao.jooq.generated.Tables.VIRTUAL_ENVIRONMENTS
+    val rows = SqlServer
+      .getInstance()
+      .createDSLContext()
+      .deleteFrom(VIRTUAL_ENVIRONMENTS)
+      .where(
+        VIRTUAL_ENVIRONMENTS.VEID
+          .eq(veid)
+          .and(VIRTUAL_ENVIRONMENTS.UID.eq(uid))
+      )
+      .execute()
+    rows > 0
+  }
+
+  // Updates an existing PVE row owned by `uid`. Returns true if a row was
+  // updated, false if no matching row was found.
+  def updatePve(veid: Int, uid: Int, name: String, packagesJson: String): 
Boolean = {
+    import org.apache.texera.dao.jooq.generated.Tables.VIRTUAL_ENVIRONMENTS
+    val rows = SqlServer
+      .getInstance()
+      .createDSLContext()
+      .update(VIRTUAL_ENVIRONMENTS)
+      .set(VIRTUAL_ENVIRONMENTS.NAME, name)
+      .set(VIRTUAL_ENVIRONMENTS.PACKAGES, JSONB.valueOf(packagesJson))
+      .where(
+        VIRTUAL_ENVIRONMENTS.VEID
+          .eq(veid)
+          .and(VIRTUAL_ENVIRONMENTS.UID.eq(uid))
+      )
+      .execute()
+    rows > 0
+  }
+
+  // Persists a PVE spec (name + packages JSON) for the given user. Returns 
the new veid.
+  def savePve(uid: Int, name: String, packagesJson: String): Int = {
+    val row = new VirtualEnvironments()
+    row.setUid(uid)
+    row.setName(name)
+    row.setPackages(JSONB.valueOf(packagesJson))
+    val dao = new VirtualEnvironmentsDao(
+      SqlServer.getInstance().createDSLContext().configuration
+    )
+    dao.insert(row)
+    row.getVeid
+  }
+
   // returns list of PVE names and corresponding user packages for a given CU
   def getEnvironments(cuid: Int): List[PvePackageResponse] = {
 
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
index ac07616d50..f404416731 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
@@ -19,7 +19,14 @@
 
 package org.apache.texera.web.resource.pythonvirtualenvironment
 
+import com.fasterxml.jackson.core.`type`.TypeReference
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import com.typesafe.scalalogging.LazyLogging
+import io.dropwizard.auth.Auth
+import org.apache.texera.auth.SessionUser
 import org.apache.texera.config.KubernetesConfig
+import org.jooq.exception.DataAccessException
 
 import javax.ws.rs._
 import javax.ws.rs.core.MediaType
@@ -29,9 +36,18 @@ import javax.ws.rs.DELETE
 import javax.ws.rs.PathParam
 import javax.ws.rs.core.Response
 
+object PveResource {
+  case class SavePvePayload(name: String, packages: Map[String, String])
+  case class PveListItem(veid: Int, name: String, packages: Map[String, 
String])
+
+  private val mapper: ObjectMapper = new 
ObjectMapper().registerModule(DefaultScalaModule)
+  private val packagesType = new TypeReference[java.util.Map[String, String]] 
{}
+}
+
 @Path("/pve")
 @Consumes(Array(MediaType.APPLICATION_JSON))
-class PveResource {
+class PveResource extends LazyLogging {
+  import PveResource._
   // --------------------------------------------------
   // Get system packages
   // --------------------------------------------------
@@ -47,13 +63,101 @@ class PveResource {
       Map("system" -> systemPkgs).asJava
     } catch {
       case e: Exception =>
-        e.printStackTrace()
+        logger.error("Failed to get system packages", e)
         throw new InternalServerErrorException(
           "Failed to get system packages."
         )
     }
   }
 
+  // --------------------------------------------------
+  // List all PVEs for the current user from the database
+  // --------------------------------------------------
+  @GET
+  @Path("/db")
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def listPves(@Auth sessionUser: SessionUser): java.util.List[PveListItem] = {
+    PveManager
+      .listPvesForUser(sessionUser.getUid.intValue())
+      .map { stored =>
+        val packages: Map[String, String] =
+          try mapper.readValue(stored.packagesJson, packagesType).asScala.toMap
+          catch { case _: Throwable => Map.empty[String, String] }
+        PveListItem(stored.veid, stored.name, packages)
+      }
+      .asJava
+  }
+
+  // --------------------------------------------------
+  // Update a PVE row owned by the current user
+  // --------------------------------------------------
+  @PUT
+  @Path("/db/{veid}")
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def updatePve(
+      @PathParam("veid") veid: Int,
+      payload: SavePvePayload,
+      @Auth sessionUser: SessionUser
+  ): Response = {
+    val name = Option(payload.name).map(_.trim).getOrElse("")
+    if (!PveManager.isValidPveName(name)) {
+      return Response.status(Response.Status.BAD_REQUEST).entity("invalid 
name").build()
+    }
+    try {
+      val packagesJson = mapper.writeValueAsString(payload.packages)
+      val updated = PveManager.updatePve(veid, sessionUser.getUid.intValue(), 
name, packagesJson)
+      if (updated) Response.ok(Map("veid" -> veid).asJava).build()
+      else Response.status(Response.Status.NOT_FOUND).build()
+    } catch {
+      case e: DataAccessException if e.sqlState() == "23505" =>
+        Response
+          .status(Response.Status.CONFLICT)
+          .entity(s"""An environment named "$name" already exists.""")
+          .build()
+      case e: Exception =>
+        logger.error("Failed to update PVE", e)
+        throw new InternalServerErrorException(s"Failed to update PVE: 
${e.getMessage}")
+    }
+  }
+
+  // --------------------------------------------------
+  // Delete a PVE row owned by the current user
+  // --------------------------------------------------
+  @DELETE
+  @Path("/db/{veid}")
+  def deletePveFromDb(@PathParam("veid") veid: Int, @Auth sessionUser: 
SessionUser): Response = {
+    val deleted = PveManager.deletePveFromDb(veid, 
sessionUser.getUid.intValue())
+    if (deleted) Response.noContent().build()
+    else Response.status(Response.Status.NOT_FOUND).build()
+  }
+
+  // --------------------------------------------------
+  // Save a PVE (name + packages) to the database for the current user
+  // --------------------------------------------------
+  @POST
+  @Path("/db")
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def savePve(payload: SavePvePayload, @Auth sessionUser: SessionUser): 
Response = {
+    val name = Option(payload.name).map(_.trim).getOrElse("")
+    if (!PveManager.isValidPveName(name)) {
+      return Response.status(Response.Status.BAD_REQUEST).entity("invalid 
name").build()
+    }
+    try {
+      val packagesJson = mapper.writeValueAsString(payload.packages)
+      val veid = PveManager.savePve(sessionUser.getUid.intValue(), name, 
packagesJson)
+      Response.status(Response.Status.CREATED).entity(Map("veid" -> 
veid).asJava).build()
+    } catch {
+      case e: DataAccessException if e.sqlState() == "23505" =>
+        Response
+          .status(Response.Status.CONFLICT)
+          .entity(s"""An environment named "$name" already exists.""")
+          .build()
+      case e: Exception =>
+        logger.error("Failed to save PVE", e)
+        throw new InternalServerErrorException(s"Failed to save PVE: 
${e.getMessage}")
+    }
+  }
+
   // --------------------------------------------------
   // Fetch PVEs and Installed User Packages
   // --------------------------------------------------
@@ -80,7 +184,7 @@ class PveResource {
       Response.ok(pves).build()
     } catch {
       case e: Exception =>
-        e.printStackTrace()
+        logger.error("Failed to get PVEs", e)
         throw new InternalServerErrorException(s"Failed to get PVEs: 
${e.getMessage}")
     }
   }
diff --git 
a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
index 92e83615c9..f0de0fa24a 100644
--- 
a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
@@ -19,25 +19,56 @@
 
 package org.apache.texera.web.resource.pythonvirtualenvironment
 
-import org.scalatest.BeforeAndAfterEach
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.Tables.VIRTUAL_ENVIRONMENTS
+import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import 
org.apache.texera.web.resource.pythonvirtualenvironment.PveResource.SavePvePayload
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
 import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 
 import java.nio.file.{Files, Path, Paths}
+import java.util.UUID
 import java.util.concurrent.LinkedBlockingQueue
+import javax.ws.rs.core.Response
 import scala.jdk.CollectionConverters._
 
-class PveResourceSpec extends AnyFlatSpec with Matchers with 
BeforeAndAfterEach {
+class PveResourceSpec
+    extends AnyFlatSpec
+    with Matchers
+    with BeforeAndAfterAll
+    with BeforeAndAfterEach
+    with MockTexeraDB {
 
   private val testCuid = 256
+  private val testUid = 8000 + scala.util.Random.nextInt(1000)
   private var testPveName: String = _
   private var testRoot: Path = _
   private var queue: LinkedBlockingQueue[String] = _
 
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+    val userDao = new UserDao(getDSLContext.configuration())
+    val user = new User
+    user.setUid(testUid)
+    user.setName("pve_resource_spec_user")
+    user.setEmail(s"user_${UUID.randomUUID()}@example.com")
+    user.setPassword("password")
+    userDao.insert(user)
+  }
+
+  override protected def afterAll(): Unit = shutdownDB()
+
   override protected def beforeEach(): Unit = {
     testPveName = s"testenv${System.currentTimeMillis()}"
     testRoot = Paths.get("/tmp/texera-pve/venvs").resolve(testCuid.toString)
     queue = new LinkedBlockingQueue[String]()
+    getDSLContext
+      .deleteFrom(VIRTUAL_ENVIRONMENTS)
+      .where(VIRTUAL_ENVIRONMENTS.UID.eq(testUid))
+      .execute()
   }
 
   override protected def afterEach(): Unit = {
@@ -173,4 +204,110 @@ class PveResourceSpec extends AnyFlatSpec with Matchers 
with BeforeAndAfterEach
     PveManager.getPythonBin(testCuid, "name with spaces") shouldBe None
     PveManager.getPythonBin(testCuid, "name;rm") shouldBe None
   }
+
+  "PveManager.savePve + listPvesForUser" should "round-trip a row for the 
owning user" in {
+    val veid = PveManager.savePve(testUid, "env-a", """{"numpy":"==1.26.0"}""")
+    veid should be > 0
+
+    val rows = PveManager.listPvesForUser(testUid)
+    rows.map(_.name) should contain("env-a")
+    val row = rows.find(_.veid == veid).get
+    row.name shouldBe "env-a"
+    row.packagesJson should include(""""numpy"""")
+    row.packagesJson should include(""""==1.26.0"""")
+  }
+
+  "PveManager.updatePve" should "mutate an owned row and refuse rows owned by 
someone else" in {
+    val veid = PveManager.savePve(testUid, "env-b", "{}")
+
+    PveManager.updatePve(veid, testUid, "env-b-renamed", """{"pandas":""}""") 
shouldBe true
+
+    val updated = PveManager.listPvesForUser(testUid).find(_.veid == veid).get
+    updated.name shouldBe "env-b-renamed"
+    updated.packagesJson should include(""""pandas"""")
+
+    val otherUid = testUid + 1
+    PveManager.updatePve(veid, otherUid, "hijacked", "{}") shouldBe false
+    PveManager.listPvesForUser(testUid).find(_.veid == veid).get.name shouldBe 
"env-b-renamed"
+  }
+
+  "PveManager.deletePveFromDb" should "remove an owned row and return false 
for missing veids" in {
+    val veid = PveManager.savePve(testUid, "env-c", "{}")
+
+    PveManager.deletePveFromDb(veid, testUid) shouldBe true
+    PveManager.listPvesForUser(testUid).map(_.veid) should not contain veid
+
+    PveManager.deletePveFromDb(veid, testUid) shouldBe false
+    PveManager.deletePveFromDb(-1, testUid) shouldBe false
+  }
+
+  // Builds a SessionUser carrying testUid so resource-layer methods can read
+  // the owning user without going through real JWT auth.
+  private def sessionUser: SessionUser = {
+    val user = new User
+    user.setUid(testUid)
+    new SessionUser(user)
+  }
+
+  "PveResource.listPves" should "return every row owned by the current user" 
in {
+    PveManager.savePve(testUid, "env-1", """{"numpy":"==1.26.0"}""")
+    PveManager.savePve(testUid, "env-2", "{}")
+
+    val items = new PveResource().listPves(sessionUser).asScala
+    items.map(_.name).toSet shouldBe Set("env-1", "env-2")
+  }
+
+  "PveResource.savePve" should "create a new row and return 201" in {
+    val resp =
+      new PveResource().savePve(SavePvePayload("env-new", Map("numpy" -> 
"==1.26.0")), sessionUser)
+    resp.getStatus shouldBe Response.Status.CREATED.getStatusCode
+  }
+
+  it should "return 400 for an invalid name" in {
+    val resp =
+      new PveResource().savePve(SavePvePayload("bad name with spaces", 
Map.empty), sessionUser)
+    resp.getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode
+  }
+
+  it should "return 409 when the user already has an env with that name" in {
+    PveManager.savePve(testUid, "env-dup", "{}")
+    val resp = new PveResource().savePve(SavePvePayload("env-dup", Map.empty), 
sessionUser)
+    resp.getStatus shouldBe Response.Status.CONFLICT.getStatusCode
+  }
+
+  "PveResource.updatePve" should "rename an owned row and return 200" in {
+    val veid = PveManager.savePve(testUid, "env-original", "{}")
+    val resp =
+      new PveResource().updatePve(veid, SavePvePayload("env-renamed", 
Map.empty), sessionUser)
+    resp.getStatus shouldBe Response.Status.OK.getStatusCode
+  }
+
+  it should "return 400 for an invalid name" in {
+    val resp = new PveResource().updatePve(1, SavePvePayload("bad name", 
Map.empty), sessionUser)
+    resp.getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode
+  }
+
+  it should "return 404 for a veid the user doesn't own" in {
+    val resp = new PveResource().updatePve(-1, SavePvePayload("env-x", 
Map.empty), sessionUser)
+    resp.getStatus shouldBe Response.Status.NOT_FOUND.getStatusCode
+  }
+
+  it should "return 409 when renaming onto a name the user already uses" in {
+    PveManager.savePve(testUid, "env-existing", "{}")
+    val target = PveManager.savePve(testUid, "env-other", "{}")
+    val resp =
+      new PveResource().updatePve(target, SavePvePayload("env-existing", 
Map.empty), sessionUser)
+    resp.getStatus shouldBe Response.Status.CONFLICT.getStatusCode
+  }
+
+  "PveResource.deletePveFromDb" should "delete an owned row and return 204" in 
{
+    val veid = PveManager.savePve(testUid, "env-todelete", "{}")
+    val resp = new PveResource().deletePveFromDb(veid, sessionUser)
+    resp.getStatus shouldBe Response.Status.NO_CONTENT.getStatusCode
+  }
+
+  it should "return 404 for a veid the user doesn't own" in {
+    val resp = new PveResource().deletePveFromDb(-1, sessionUser)
+    resp.getStatus shouldBe Response.Status.NOT_FOUND.getStatusCode
+  }
 }
diff --git a/frontend/src/app/app-routing.constant.ts 
b/frontend/src/app/app-routing.constant.ts
index 6e06f72520..e0b2c9eab0 100644
--- a/frontend/src/app/app-routing.constant.ts
+++ b/frontend/src/app/app-routing.constant.ts
@@ -35,6 +35,7 @@ export const USER_WORKFLOW = `${USER}/workflow`;
 export const USER_DATASET = `${USER}/dataset`;
 export const USER_DATASET_CREATE = `${USER_DATASET}/create`;
 export const USER_COMPUTING_UNIT = `${USER}/compute`;
+export const USER_PYTHON_VENV = `${USER}/python-venv`;
 export const USER_QUOTA = `${USER}/quota`;
 export const USER_DISCUSSION = `${USER}/discussion`;
 
diff --git a/frontend/src/app/app-routing.module.ts 
b/frontend/src/app/app-routing.module.ts
index 8e5a44903e..78ccf0232c 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -25,6 +25,7 @@ import { UserQuotaComponent } from 
"./dashboard/component/user/user-quota/user-q
 import { UserProjectSectionComponent } from 
"./dashboard/component/user/user-project/user-project-section/user-project-section.component";
 import { UserProjectComponent } from 
"./dashboard/component/user/user-project/user-project.component";
 import { UserComputingUnitComponent } from 
"./dashboard/component/user/user-computing-unit/user-computing-unit.component";
+import { UserVenvComponent } from 
"./dashboard/component/user/user-venv/user-venv.component";
 import { WorkspaceComponent } from "./workspace/component/workspace.component";
 import { AboutComponent } from "./hub/component/about/about.component";
 import { AuthGuardService } from "./common/service/user/auth-guard.service";
@@ -128,6 +129,10 @@ routes.push({
           path: "compute",
           component: UserComputingUnitComponent,
         },
+        {
+          path: "python-venv",
+          component: UserVenvComponent,
+        },
         {
           path: "quota",
           component: UserQuotaComponent,
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 5ddb97944a..524146cf75 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -193,6 +193,7 @@ import { NzCheckboxModule } from "ng-zorro-antd/checkbox";
 import { RegistrationRequestModalComponent } from 
"./common/service/user/registration-request-modal/registration-request-modal.component";
 import { UserComputingUnitComponent } from 
"./dashboard/component/user/user-computing-unit/user-computing-unit.component";
 import { UserComputingUnitListItemComponent } from 
"./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component";
+import { UserVenvComponent } from 
"./dashboard/component/user/user-venv/user-venv.component";
 
 registerLocaleData(en);
 
@@ -363,6 +364,7 @@ registerLocaleData(en);
     MarkdownDescriptionComponent,
     UserComputingUnitComponent,
     UserComputingUnitListItemComponent,
+    UserVenvComponent,
   ],
   providers: [
     provideNzI18n(en_US),
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html 
b/frontend/src/app/dashboard/component/dashboard.component.html
index 9014ea37e5..c01def869b 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.html
+++ b/frontend/src/app/dashboard/component/dashboard.component.html
@@ -109,6 +109,17 @@
               nzType="deployment-unit"></span>
             <span>Compute</span>
           </li>
+          <li
+            nz-menu-item
+            nz-tooltip="Manage saved Python virtual environments"
+            nzMatchRouter="true"
+            nzTooltipPlacement="right"
+            [routerLink]="USER_PYTHON_VENV">
+            <span
+              nz-icon
+              nzType="python"></span>
+            <span>Environments</span>
+          </li>
           <li
             *ngIf="sidebarTabs.quota_enabled"
             nz-menu-item
diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts 
b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
index a53244b3cd..10352e3b57 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
@@ -283,7 +283,7 @@ describe("DashboardComponent", () => {
     };
     fixture.detectChanges();
 
-    // 6 "Your Work" links + 4 admin links + 1 about link = 11
-    
expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(11);
+    // 7 "Your Work" links (incl. Python Venvs) + 4 admin links + 1 about link 
= 12
+    
expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(12);
   });
 });
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts 
b/frontend/src/app/dashboard/component/dashboard.component.ts
index cec80766fc..01c05b0e52 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -38,6 +38,7 @@ import {
   USER_DATASET,
   USER_DISCUSSION,
   USER_PROJECT,
+  USER_PYTHON_VENV,
   USER_QUOTA,
   USER_WORKFLOW,
 } from "../../app-routing.constant";
@@ -109,6 +110,7 @@ export class DashboardComponent implements OnInit {
   protected readonly USER_WORKFLOW = USER_WORKFLOW;
   protected readonly USER_DATASET = USER_DATASET;
   protected readonly USER_COMPUTING_UNIT = USER_COMPUTING_UNIT;
+  protected readonly USER_PYTHON_VENV = USER_PYTHON_VENV;
   protected readonly USER_QUOTA = USER_QUOTA;
   protected readonly USER_DISCUSSION = USER_DISCUSSION;
   protected readonly ADMIN_USER = ADMIN_USER;
diff --git 
a/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.html 
b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.html
new file mode 100644
index 0000000000..e29f9810af
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.html
@@ -0,0 +1,183 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+<div class="section-container subsection-grid-container">
+  <nz-card class="section-title">
+    <h2 class="page-title">Environments</h2>
+    <div class="button-group">
+      <button
+        nz-button
+        class="create-btn"
+        (click)="showPveModal()"
+        title="Create Environment">
+        <i
+          nz-icon
+          nzType="file-add"
+          nzTheme="outline"></i>
+        <span>Create Environment</span>
+      </button>
+    </div>
+  </nz-card>
+
+  <nz-card
+    class="section-list-container"
+    [nzBodyStyle]="{ height: '100%' }">
+    <div class="python-env-page">
+      <div
+        *ngIf="pves.length === 0"
+        class="python-env-page-empty">
+        No environments yet. Click <strong>Create Environment</strong> to 
create one.
+      </div>
+
+      <ul
+        *ngIf="pves.length > 0"
+        class="python-env-page-list">
+        <li
+          *ngFor="let pve of pves; let i = index; trackBy: trackByVeid"
+          class="python-env-page-item"
+          (click)="openExistingPve(i)">
+          <span class="python-env-name">{{ pve.name || "(unnamed)" }}</span>
+          <i
+            nz-icon
+            nzType="delete"
+            class="python-env-delete-icon"
+            nz-tooltip
+            nzTooltipTitle="Delete environment"
+            (click)="confirmDeletePve(i); $event.stopPropagation()"
+            role="button"
+            aria-label="Delete environment">
+          </i>
+        </li>
+      </ul>
+    </div>
+  </nz-card>
+</div>
+
+<nz-modal
+  nzWrapClassName="pve-modal"
+  nzClassName="pve-modal"
+  [nzVisible]="pveModalVisible"
+  nzTitle="Python Environment"
+  (nzOnCancel)="closePveModal()"
+  [nzFooter]="customFooter">
+  <ng-template #customFooter>
+    <div class="footer-all">
+      <button
+        nz-button
+        nzType="default"
+        (click)="closePveModal()">
+        Close
+      </button>
+      <button
+        nz-button
+        nzType="primary"
+        [disabled]="!currentDraft?.name?.trim()"
+        [nzLoading]="saving"
+        (click)="saveEnvironment()">
+        Save
+      </button>
+    </div>
+  </ng-template>
+
+  <ng-container *nzModalContent>
+    <div
+      *ngIf="currentDraft as pve"
+      class="ve-form">
+      <div class="fieldRow">
+        <label class="fieldLabel">Virtual Environment Name</label>
+        <input
+          nz-input
+          class="fieldInput"
+          placeholder="Environment Name"
+          [(ngModel)]="pve.name" />
+      </div>
+
+      <div class="new-packages-section">
+        <div
+          class="package-row user-package-header-row"
+          *ngIf="pve.newPackages.length > 0">
+          <div class="user-package-inputs">
+            <div class="package-column-label">Package</div>
+            <div></div>
+            <div class="package-column-label">Version</div>
+            <div></div>
+          </div>
+        </div>
+
+        <div
+          *ngFor="let pkg of pve.newPackages; let i = index"
+          class="package-row">
+          <div class="user-package-inputs">
+            <div class="field">
+              <input
+                nz-input
+                placeholder="Package Name"
+                [(ngModel)]="pve.newPackages[i].name" />
+            </div>
+            <div class="field operator operator-select">
+              <nz-select
+                nzPlaceHolder="Select"
+                nzCentered
+                [(ngModel)]="pve.newPackages[i].versionOp">
+                <nz-option
+                  nzValue="=="
+                  nzLabel="=="></nz-option>
+                <nz-option
+                  nzValue=">="
+                  nzLabel=">="></nz-option>
+                <nz-option
+                  nzValue="<="
+                  nzLabel="<="></nz-option>
+              </nz-select>
+            </div>
+            <div class="field">
+              <input
+                nz-input
+                placeholder="Package Version"
+                [(ngModel)]="pve.newPackages[i].version" />
+            </div>
+            <button
+              nz-button
+              nzType="default"
+              nzShape="circle"
+              nzDanger
+              [class.highlighted-btn]="pkg.deleteToggle"
+              (click)="togglePackageDelete(pkg)">
+              <i
+                nz-icon
+                nzType="delete"></i>
+            </button>
+          </div>
+        </div>
+      </div>
+
+      <div class="add-btn">
+        <button
+          nz-button
+          nzType="primary"
+          nzShape="circle"
+          (click)="addPackage()">
+          <i
+            nz-icon
+            nzType="plus"></i>
+        </button>
+      </div>
+    </div>
+  </ng-container>
+</nz-modal>
diff --git 
a/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.scss 
b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.scss
new file mode 100644
index 0000000000..141c2a980d
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.scss
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+@import "../../section-style";
+@import "../../button-style";
+
+.subsection-grid-container {
+  min-width: 100%;
+  width: 100%;
+  min-height: 100%;
+  height: 100%;
+}
+
+.python-env-name {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.python-env-delete-icon {
+  flex-shrink: 0;
+  color: rgba(0, 0, 0, 0.55);
+  cursor: pointer;
+
+  &:hover {
+    color: #ff4d4f;
+  }
+}
+
+.python-env-page {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  width: 100%;
+
+  .python-env-page-empty {
+    padding: 24px;
+    color: rgba(0, 0, 0, 0.55);
+    text-align: center;
+    border: 1px dashed #d9d9d9;
+    border-radius: 4px;
+  }
+
+  .python-env-page-list {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    width: 100%;
+  }
+
+  .python-env-page-item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    width: 100%;
+    padding: 16px 20px;
+    box-sizing: border-box;
+    background: #ffffff;
+    border: 1px solid #eef0f3;
+    border-radius: 6px;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
+    cursor: pointer;
+    transition:
+      background 0.15s ease,
+      box-shadow 0.15s ease;
+
+    &:hover {
+      background: #fafafa;
+      box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
+    }
+
+    .python-env-name {
+      font-size: 15px;
+      font-weight: 500;
+    }
+  }
+}
diff --git 
a/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.ts 
b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.ts
new file mode 100644
index 0000000000..0e84d3a298
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-venv/user-venv.component.ts
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { FormsModule } from "@angular/forms";
+import { NgFor, NgIf } from "@angular/common";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzCardComponent } from "ng-zorro-antd/card";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzInputDirective } from "ng-zorro-antd/input";
+import { NzModalComponent, NzModalContentDirective, NzModalService } from 
"ng-zorro-antd/modal";
+import { NzOptionComponent, NzSelectComponent } from "ng-zorro-antd/select";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+
+import { NotificationService } from 
"../../../../common/service/notification/notification.service";
+import {
+  UserPveRecord,
+  WorkflowPveService,
+} from 
"../../../../workspace/service/virtual-environment/virtual-environment.service";
+
+type PveUserPackageRow = {
+  name: string;
+  versionOp: "==" | ">=" | "<=";
+  version: string;
+  deleteToggle?: boolean;
+};
+
+type PveDraft = {
+  veid?: number;
+  name: string;
+  newPackages: PveUserPackageRow[];
+};
+
+@UntilDestroy()
+@Component({
+  selector: "texera-user-venv",
+  templateUrl: "./user-venv.component.html",
+  styleUrls: ["./user-venv.component.scss"],
+  imports: [
+    NgIf,
+    NgFor,
+    FormsModule,
+    NzButtonComponent,
+    NzCardComponent,
+    NzIconDirective,
+    NzInputDirective,
+    NzModalComponent,
+    NzModalContentDirective,
+    NzSelectComponent,
+    NzOptionComponent,
+    NzTooltipDirective,
+  ],
+})
+export class UserVenvComponent implements OnInit {
+  // The user's PVEs (fetched from the DB), rendered as the page list.
+  pves: PveDraft[] = [];
+
+  // The single PVE currently being edited in the modal. Null when modal is 
closed.
+  currentDraft: PveDraft | null = null;
+
+  pveModalVisible = false;
+  saving = false;
+
+  constructor(
+    private workflowPveService: WorkflowPveService,
+    private notificationService: NotificationService,
+    private modalService: NzModalService
+  ) {}
+
+  ngOnInit(): void {
+    this.refreshPves();
+  }
+
+  confirmDeletePve(index: number): void {
+    const target = this.pves[index];
+    if (!target) return;
+    const name = target.name || "(unnamed)";
+    this.modalService.confirm({
+      nzTitle: `Delete environment "${name}"?`,
+      nzContent: "This permanently removes the environment from the database.",
+      nzOkText: "Delete",
+      nzOkDanger: true,
+      nzOnOk: () => this.deletePve(index),
+    });
+  }
+
+  private refreshPves(): void {
+    this.workflowPveService
+      .listUserPves()
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: records => {
+          this.pves = records.map(record => this.recordToDraft(record));
+        },
+        error: (err: unknown) => {
+          console.error("Failed to fetch Python environments", err);
+          this.notificationService.error("Failed to fetch Python 
environments.");
+        },
+      });
+  }
+
+  private recordToDraft(record: UserPveRecord): PveDraft {
+    const newPackages: PveUserPackageRow[] = Object.entries(record.packages ?? 
{}).map(([name, raw]) => {
+      const match = raw?.match?.(/^(==|>=|<=)(.*)$/);
+      return {
+        name,
+        versionOp: (match ? match[1] : "==") as "==" | ">=" | "<=",
+        version: match ? match[2] : raw ?? "",
+      };
+    });
+    return {
+      veid: record.veid,
+      name: record.name,
+      newPackages,
+    };
+  }
+
+  showPveModal(): void {
+    this.currentDraft = {
+      name: "",
+      newPackages: [],
+    };
+    this.pveModalVisible = true;
+  }
+
+  openExistingPve(index: number): void {
+    const source = this.pves[index];
+    if (!source) return;
+    this.currentDraft = {
+      veid: source.veid,
+      name: source.name,
+      newPackages: source.newPackages.map(p => ({ ...p })),
+    };
+    this.pveModalVisible = true;
+  }
+
+  closePveModal(): void {
+    this.pveModalVisible = false;
+    this.currentDraft = null;
+  }
+
+  addPackage(): void {
+    this.currentDraft?.newPackages.push({ name: "", versionOp: "==", version: 
"" });
+  }
+
+  togglePackageDelete(pkg: PveUserPackageRow): void {
+    pkg.deleteToggle = !pkg.deleteToggle;
+  }
+
+  saveEnvironment(): void {
+    const draft = this.currentDraft;
+    if (!draft) return;
+
+    const trimmedName = draft.name.trim();
+    if (!trimmedName) {
+      this.notificationService.error("Environment name is required.");
+      return;
+    }
+
+    const conflict = this.pves.find(p => p.name.trim() === trimmedName && 
p.veid !== draft.veid);
+    if (conflict) {
+      this.notificationService.error(`An environment named "${trimmedName}" 
already exists.`);
+      return;
+    }
+
+    const packages: Record<string, string> = {};
+    for (const row of draft.newPackages) {
+      if (row.deleteToggle) continue;
+      const pkgName = row.name.trim();
+      if (!pkgName) continue;
+      const pkgVersion = (row.version ?? "").trim();
+      if (packages[pkgName] !== undefined) {
+        this.notificationService.error(`Duplicate package "${pkgName}".`);
+        return;
+      }
+      packages[pkgName] = pkgVersion ? `${row.versionOp}${pkgVersion}` : "";
+    }
+
+    this.saving = true;
+    const request$ =
+      draft.veid === undefined
+        ? this.workflowPveService.savePve(trimmedName, packages)
+        : this.workflowPveService.updateUserPve(draft.veid, trimmedName, 
packages);
+
+    request$.pipe(untilDestroyed(this)).subscribe({
+      next: () => {
+        this.saving = false;
+        this.notificationService.success(`Saved environment 
"${trimmedName}".`);
+        this.closePveModal();
+        this.refreshPves();
+      },
+      error: (err: unknown) => {
+        this.saving = false;
+        console.error("Failed to save PVE", err);
+        this.notificationService.error("Failed to save Python environment.");
+      },
+    });
+  }
+
+  trackByVeid(_: number, pve: PveDraft): number | undefined {
+    return pve.veid;
+  }
+
+  deletePve(index: number): void {
+    const target = this.pves[index];
+    if (!target || target.veid === undefined) return;
+
+    const veid = target.veid;
+    const name = target.name || "(unnamed)";
+
+    this.workflowPveService
+      .deleteUserPve(veid)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: () => {
+          this.notificationService.success(`Deleted environment "${name}".`);
+          this.refreshPves();
+        },
+        error: (err: unknown) => {
+          console.error("Failed to delete PVE", err);
+          this.notificationService.error("Failed to delete Python 
environment.");
+        },
+      });
+  }
+}
diff --git 
a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.spec.ts
 
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.spec.ts
new file mode 100644
index 0000000000..c194c115cc
--- /dev/null
+++ 
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.spec.ts
@@ -0,0 +1,84 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from 
"@angular/common/http/testing";
+import { UserPveRecord, WorkflowPveService } from 
"./virtual-environment.service";
+import { commonTestProviders } from "../../../common/testing/test-utils";
+
+describe("WorkflowPveService", () => {
+  let service: WorkflowPveService;
+  let httpTestingController: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [WorkflowPveService, ...commonTestProviders],
+    });
+    service = TestBed.inject(WorkflowPveService);
+    httpTestingController = TestBed.inject(HttpTestingController);
+  });
+
+  it("should be created", () => {
+    expect(service).toBeTruthy();
+  });
+
+  it("savePve() POSTs to /pve/db with name + packages and returns the new 
veid", () => {
+    const packages = { numpy: "==1.26.0" };
+    service.savePve("env-a", packages).subscribe(resp => {
+      expect(resp.veid).toBe(42);
+    });
+
+    const req = httpTestingController.expectOne("/pve/db");
+    expect(req.request.method).toBe("POST");
+    expect(req.request.body).toEqual({ name: "env-a", packages });
+    req.flush({ veid: 42 });
+  });
+
+  it("updateUserPve() PUTs to /pve/db/{veid} with name + packages", () => {
+    const packages = { pandas: "" };
+    service.updateUserPve(7, "env-b", packages).subscribe(resp => {
+      expect(resp.veid).toBe(7);
+    });
+
+    const req = httpTestingController.expectOne("/pve/db/7");
+    expect(req.request.method).toBe("PUT");
+    expect(req.request.body).toEqual({ name: "env-b", packages });
+    req.flush({ veid: 7 });
+  });
+
+  it("listUserPves() GETs /pve/db and returns the array of records", () => {
+    const records: UserPveRecord[] = [{ veid: 1, name: "env-a", packages: { 
numpy: "==1.26.0" } }];
+    service.listUserPves().subscribe(resp => {
+      expect(resp).toEqual(records);
+    });
+
+    const req = httpTestingController.expectOne("/pve/db");
+    expect(req.request.method).toBe("GET");
+    req.flush(records);
+  });
+
+  it("deleteUserPve() DELETEs /pve/db/{veid}", () => {
+    service.deleteUserPve(9).subscribe();
+
+    const req = httpTestingController.expectOne("/pve/db/9");
+    expect(req.request.method).toBe("DELETE");
+    req.flush(null);
+  });
+});
diff --git 
a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
 
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
index d3108e4756..d399f643a0 100644
--- 
a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
+++ 
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
@@ -31,10 +31,32 @@ export interface PvePackageResponse {
   userPackages: string[];
 }
 
+export interface UserPveRecord {
+  veid: number;
+  name: string;
+  packages: Record<string, string>;
+}
+
 @Injectable({ providedIn: "root" })
 export class WorkflowPveService {
   constructor(private http: HttpClient) {}
 
+  savePve(name: string, packages: Record<string, string>): Observable<{ veid: 
number }> {
+    return this.http.post<{ veid: number }>("/pve/db", { name, packages });
+  }
+
+  updateUserPve(veid: number, name: string, packages: Record<string, string>): 
Observable<{ veid: number }> {
+    return this.http.put<{ veid: number }>(`/pve/db/${veid}`, { name, packages 
});
+  }
+
+  listUserPves(): Observable<UserPveRecord[]> {
+    return this.http.get<UserPveRecord[]>("/pve/db");
+  }
+
+  deleteUserPve(veid: number): Observable<void> {
+    return this.http.delete<void>(`/pve/db/${veid}`);
+  }
+
   getAccessToken(): string | null {
     const token = AuthService.getAccessToken();
     return token && token.trim().length > 0 ? token : null;
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 4825321b20..39119f538b 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -29,6 +29,10 @@
         <sqlFile path="sql/updates/23.sql"/>
     </changeSet>
 
+    <changeSet id="24" author="sarahasad23">
+        <sqlFile path="sql/updates/24.sql"/>
+    </changeSet>
+
     <!-- example changeSet
     <changeSet id="1" author="author">
         <sqlFile path="sql/updates/1.sql"/>
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index 83ed0abb50..26b009e420 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -76,6 +76,7 @@ DROP TABLE IF EXISTS site_settings CASCADE;
 DROP TABLE IF EXISTS computing_unit_user_access CASCADE;
 DROP TABLE IF EXISTS notebook CASCADE;
 DROP TABLE IF EXISTS workflow_notebook_mapping CASCADE;
+DROP TABLE IF EXISTS virtual_environments CASCADE;
 
 -- ============================================
 -- 4. Create PostgreSQL enum types
@@ -213,6 +214,17 @@ CREATE TABLE IF NOT EXISTS workflow_computing_unit
     FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE
 );
 
+-- virtual_environments table
+CREATE TABLE IF NOT EXISTS virtual_environments
+(
+    veid     SERIAL PRIMARY KEY,
+    uid      INT           NOT NULL,
+    name     VARCHAR(128)  NOT NULL,
+    packages JSONB         NOT NULL DEFAULT '{}'::jsonb,
+    FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE,
+    UNIQUE (uid, name)
+);
+
 -- workflow_executions
 CREATE TABLE IF NOT EXISTS workflow_executions
 (
diff --git a/sql/updates/24.sql b/sql/updates/24.sql
new file mode 100644
index 0000000000..8d802dff22
--- /dev/null
+++ b/sql/updates/24.sql
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+
+-- Adds the virtual_environments table, used to persist user-owned virtual
+-- environment metadata (name + installed package versions) instead of
+-- relying on the filesystem layout under /tmp/texera-pve/venvs.
+CREATE TABLE IF NOT EXISTS virtual_environments
+(
+    veid     SERIAL PRIMARY KEY,
+    uid      INT           NOT NULL,
+    name     VARCHAR(128)  NOT NULL,
+    packages JSONB         NOT NULL DEFAULT '{}'::jsonb,
+    FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE,
+    UNIQUE (uid, name)
+);
+
+COMMIT;

Reply via email to