tech-envision commited on
Commit
f53555c
·
unverified ·
2 Parent(s): 706c060 4bec4d3

Merge pull request #46 from EnvisionMindCa/codex/make-vm-persistent-with-saved-state

Browse files
Files changed (3) hide show
  1. README.md +9 -4
  2. src/config.py +3 -0
  3. 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 so any installed packages and
18
- filesystem changes remain available. The environment includes Python and
19
- ``pip`` so complex tasks can be scripted using Python directly inside the
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, image: str = VM_IMAGE, host_dir: str = UPLOAD_DIR
 
 
 
27
  ) -> None:
28
  self._image = image
29
- self._name = f"chat-vm-{uuid.uuid4().hex[:8]}"
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
- subprocess.run(
134
- ["docker", "rm", "-f", self._name],
135
- check=False,
136
- stdout=subprocess.PIPE,
137
- stderr=subprocess.PIPE,
138
- text=True,
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(host_dir=str(Path(UPLOAD_DIR) / username))
 
 
 
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
- del cls._vms[username]
187
- del cls._counts[username]
188
 
189
  @classmethod
190
  def shutdown_all(cls) -> None:
191
  """Stop and remove all managed VMs."""
192
 
193
  with cls._lock:
194
- for vm in cls._vms.values():
195
- vm.stop()
 
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()