Spaces:
Runtime error
Runtime error
Merge pull request #46 from EnvisionMindCa/codex/make-vm-persistent-with-saved-state
Browse files- README.md +9 -4
- src/config.py +3 -0
- src/vm.py +60 -15
README.md
CHANGED
@@ -14,10 +14,10 @@ conversations can be resumed with context. One example tool is included:
|
|
14 |
while the command runs.
|
15 |
The VM is created when a chat session starts and reused for all subsequent
|
16 |
tool calls. When ``PERSIST_VMS`` is enabled (default), each user keeps the
|
17 |
-
same container across multiple chat sessions
|
18 |
-
filesystem changes remain available. The
|
19 |
-
``pip`` so complex tasks can be scripted using
|
20 |
-
terminal.
|
21 |
|
22 |
Sessions share state through an in-memory registry so that only one generation
|
23 |
can run at a time. Messages sent while a response is being produced are
|
@@ -80,6 +80,11 @@ back to ``python:3.11-slim``. This base image includes Python and ``pip`` so
|
|
80 |
packages can be installed immediately. The container has network access enabled
|
81 |
which allows fetching additional dependencies as needed.
|
82 |
|
|
|
|
|
|
|
|
|
|
|
83 |
Set ``PERSIST_VMS=0`` to revert to the previous behaviour where containers are
|
84 |
stopped once no sessions are using them.
|
85 |
|
|
|
14 |
while the command runs.
|
15 |
The VM is created when a chat session starts and reused for all subsequent
|
16 |
tool calls. When ``PERSIST_VMS`` is enabled (default), each user keeps the
|
17 |
+
same container across multiple chat sessions and across application restarts,
|
18 |
+
so any installed packages and filesystem changes remain available. The
|
19 |
+
environment includes Python and ``pip`` so complex tasks can be scripted using
|
20 |
+
Python directly inside the terminal.
|
21 |
|
22 |
Sessions share state through an in-memory registry so that only one generation
|
23 |
can run at a time. Messages sent while a response is being produced are
|
|
|
80 |
packages can be installed immediately. The container has network access enabled
|
81 |
which allows fetching additional dependencies as needed.
|
82 |
|
83 |
+
When ``PERSIST_VMS`` is ``1`` (default), containers are kept around and reused
|
84 |
+
across application restarts. Each user is assigned a stable container name, so
|
85 |
+
packages installed or files created inside the VM remain available the next
|
86 |
+
time the application starts. Set ``VM_STATE_DIR`` to specify the host directory
|
87 |
+
used for per-user persistent storage mounted inside the VM at ``/state``.
|
88 |
Set ``PERSIST_VMS=0`` to revert to the previous behaviour where containers are
|
89 |
stopped once no sessions are using them.
|
90 |
|
src/config.py
CHANGED
@@ -11,6 +11,9 @@ NUM_CTX: Final[int] = int(os.getenv("OLLAMA_NUM_CTX", "32000"))
|
|
11 |
UPLOAD_DIR: Final[str] = os.getenv("UPLOAD_DIR", str(Path.cwd() / "uploads"))
|
12 |
VM_IMAGE: Final[str] = os.getenv("VM_IMAGE", "python:3.11")
|
13 |
PERSIST_VMS: Final[bool] = os.getenv("PERSIST_VMS", "1") == "1"
|
|
|
|
|
|
|
14 |
|
15 |
SYSTEM_PROMPT: Final[str] = (
|
16 |
"You are Starlette, a professional AI assistant with advanced tool orchestration. "
|
|
|
11 |
UPLOAD_DIR: Final[str] = os.getenv("UPLOAD_DIR", str(Path.cwd() / "uploads"))
|
12 |
VM_IMAGE: Final[str] = os.getenv("VM_IMAGE", "python:3.11")
|
13 |
PERSIST_VMS: Final[bool] = os.getenv("PERSIST_VMS", "1") == "1"
|
14 |
+
VM_STATE_DIR: Final[str] = os.getenv(
|
15 |
+
"VM_STATE_DIR", str(Path.cwd() / "vm_state")
|
16 |
+
)
|
17 |
|
18 |
SYSTEM_PROMPT: Final[str] = (
|
19 |
"You are Starlette, a professional AI assistant with advanced tool orchestration. "
|
src/vm.py
CHANGED
@@ -8,13 +8,19 @@ from pathlib import Path
|
|
8 |
|
9 |
from threading import Lock
|
10 |
|
11 |
-
from .config import UPLOAD_DIR, VM_IMAGE, PERSIST_VMS
|
12 |
|
13 |
from .log import get_logger
|
14 |
|
15 |
_LOG = get_logger(__name__)
|
16 |
|
17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
class LinuxVM:
|
19 |
"""Manage a lightweight Docker-based VM.
|
20 |
|
@@ -23,13 +29,18 @@ class LinuxVM:
|
|
23 |
"""
|
24 |
|
25 |
def __init__(
|
26 |
-
self,
|
|
|
|
|
|
|
27 |
) -> None:
|
28 |
self._image = image
|
29 |
-
self._name = f"chat-vm-{
|
30 |
self._running = False
|
31 |
self._host_dir = Path(host_dir)
|
32 |
self._host_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
33 |
|
34 |
def start(self) -> None:
|
35 |
"""Start the VM if it is not already running."""
|
@@ -37,6 +48,25 @@ class LinuxVM:
|
|
37 |
return
|
38 |
|
39 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
40 |
subprocess.run(
|
41 |
["docker", "pull", self._image],
|
42 |
check=False,
|
@@ -53,6 +83,8 @@ class LinuxVM:
|
|
53 |
self._name,
|
54 |
"-v",
|
55 |
f"{self._host_dir}:/data",
|
|
|
|
|
56 |
self._image,
|
57 |
"sleep",
|
58 |
"infinity",
|
@@ -130,13 +162,22 @@ class LinuxVM:
|
|
130 |
if not self._running:
|
131 |
return
|
132 |
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
140 |
self._running = False
|
141 |
|
142 |
def __enter__(self) -> "LinuxVM":
|
@@ -161,7 +202,10 @@ class VMRegistry:
|
|
161 |
with cls._lock:
|
162 |
vm = cls._vms.get(username)
|
163 |
if vm is None:
|
164 |
-
vm = LinuxVM(
|
|
|
|
|
|
|
165 |
cls._vms[username] = vm
|
166 |
cls._counts[username] = 0
|
167 |
cls._counts[username] += 1
|
@@ -183,15 +227,16 @@ class VMRegistry:
|
|
183 |
cls._counts[username] = 0
|
184 |
if not PERSIST_VMS:
|
185 |
vm.stop()
|
186 |
-
|
187 |
-
|
188 |
|
189 |
@classmethod
|
190 |
def shutdown_all(cls) -> None:
|
191 |
"""Stop and remove all managed VMs."""
|
192 |
|
193 |
with cls._lock:
|
194 |
-
|
195 |
-
vm.
|
|
|
196 |
cls._vms.clear()
|
197 |
cls._counts.clear()
|
|
|
8 |
|
9 |
from threading import Lock
|
10 |
|
11 |
+
from .config import UPLOAD_DIR, VM_IMAGE, PERSIST_VMS, VM_STATE_DIR
|
12 |
|
13 |
from .log import get_logger
|
14 |
|
15 |
_LOG = get_logger(__name__)
|
16 |
|
17 |
|
18 |
+
def _sanitize(name: str) -> str:
|
19 |
+
"""Return a Docker-safe name fragment."""
|
20 |
+
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
|
21 |
+
return "".join(c if c in allowed else "_" for c in name)
|
22 |
+
|
23 |
+
|
24 |
class LinuxVM:
|
25 |
"""Manage a lightweight Docker-based VM.
|
26 |
|
|
|
29 |
"""
|
30 |
|
31 |
def __init__(
|
32 |
+
self,
|
33 |
+
username: str,
|
34 |
+
image: str = VM_IMAGE,
|
35 |
+
host_dir: str = UPLOAD_DIR,
|
36 |
) -> None:
|
37 |
self._image = image
|
38 |
+
self._name = f"chat-vm-{_sanitize(username)}"
|
39 |
self._running = False
|
40 |
self._host_dir = Path(host_dir)
|
41 |
self._host_dir.mkdir(parents=True, exist_ok=True)
|
42 |
+
self._state_dir = Path(VM_STATE_DIR) / _sanitize(username)
|
43 |
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
44 |
|
45 |
def start(self) -> None:
|
46 |
"""Start the VM if it is not already running."""
|
|
|
48 |
return
|
49 |
|
50 |
try:
|
51 |
+
inspect = subprocess.run(
|
52 |
+
["docker", "inspect", "-f", "{{.State.Running}}", self._name],
|
53 |
+
capture_output=True,
|
54 |
+
text=True,
|
55 |
+
)
|
56 |
+
if inspect.returncode == 0:
|
57 |
+
if inspect.stdout.strip() == "true":
|
58 |
+
self._running = True
|
59 |
+
return
|
60 |
+
subprocess.run(
|
61 |
+
["docker", "start", self._name],
|
62 |
+
check=True,
|
63 |
+
stdout=subprocess.PIPE,
|
64 |
+
stderr=subprocess.PIPE,
|
65 |
+
text=True,
|
66 |
+
)
|
67 |
+
self._running = True
|
68 |
+
return
|
69 |
+
|
70 |
subprocess.run(
|
71 |
["docker", "pull", self._image],
|
72 |
check=False,
|
|
|
83 |
self._name,
|
84 |
"-v",
|
85 |
f"{self._host_dir}:/data",
|
86 |
+
"-v",
|
87 |
+
f"{self._state_dir}:/state",
|
88 |
self._image,
|
89 |
"sleep",
|
90 |
"infinity",
|
|
|
162 |
if not self._running:
|
163 |
return
|
164 |
|
165 |
+
if PERSIST_VMS:
|
166 |
+
subprocess.run(
|
167 |
+
["docker", "stop", self._name],
|
168 |
+
check=False,
|
169 |
+
stdout=subprocess.PIPE,
|
170 |
+
stderr=subprocess.PIPE,
|
171 |
+
text=True,
|
172 |
+
)
|
173 |
+
else:
|
174 |
+
subprocess.run(
|
175 |
+
["docker", "rm", "-f", self._name],
|
176 |
+
check=False,
|
177 |
+
stdout=subprocess.PIPE,
|
178 |
+
stderr=subprocess.PIPE,
|
179 |
+
text=True,
|
180 |
+
)
|
181 |
self._running = False
|
182 |
|
183 |
def __enter__(self) -> "LinuxVM":
|
|
|
202 |
with cls._lock:
|
203 |
vm = cls._vms.get(username)
|
204 |
if vm is None:
|
205 |
+
vm = LinuxVM(
|
206 |
+
username,
|
207 |
+
host_dir=str(Path(UPLOAD_DIR) / username),
|
208 |
+
)
|
209 |
cls._vms[username] = vm
|
210 |
cls._counts[username] = 0
|
211 |
cls._counts[username] += 1
|
|
|
227 |
cls._counts[username] = 0
|
228 |
if not PERSIST_VMS:
|
229 |
vm.stop()
|
230 |
+
del cls._vms[username]
|
231 |
+
del cls._counts[username]
|
232 |
|
233 |
@classmethod
|
234 |
def shutdown_all(cls) -> None:
|
235 |
"""Stop and remove all managed VMs."""
|
236 |
|
237 |
with cls._lock:
|
238 |
+
if not PERSIST_VMS:
|
239 |
+
for vm in cls._vms.values():
|
240 |
+
vm.stop()
|
241 |
cls._vms.clear()
|
242 |
cls._counts.clear()
|