agentworks-cli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. agentworks/__init__.py +1 -0
  2. agentworks/agents/__init__.py +0 -0
  3. agentworks/agents/manager.py +1095 -0
  4. agentworks/agents/templates.py +145 -0
  5. agentworks/catalog.py +264 -0
  6. agentworks/catalog.toml +131 -0
  7. agentworks/cli.py +1462 -0
  8. agentworks/completions/__init__.py +33 -0
  9. agentworks/completions/bash.py +179 -0
  10. agentworks/completions/install.py +122 -0
  11. agentworks/completions/powershell.py +270 -0
  12. agentworks/completions/spec.py +216 -0
  13. agentworks/completions/zsh.py +256 -0
  14. agentworks/config.py +894 -0
  15. agentworks/db.py +1083 -0
  16. agentworks/doctor.py +430 -0
  17. agentworks/git_credentials/__init__.py +0 -0
  18. agentworks/git_credentials/azdo.py +29 -0
  19. agentworks/git_credentials/base.py +71 -0
  20. agentworks/git_credentials/github.py +22 -0
  21. agentworks/nerf-config.yaml +16 -0
  22. agentworks/output.py +296 -0
  23. agentworks/remote_exec.py +286 -0
  24. agentworks/sample-config.toml +289 -0
  25. agentworks/sessions/__init__.py +0 -0
  26. agentworks/sessions/console.py +164 -0
  27. agentworks/sessions/manager.py +1297 -0
  28. agentworks/sessions/templates.py +101 -0
  29. agentworks/sessions/tmux.py +503 -0
  30. agentworks/sources.py +303 -0
  31. agentworks/ssh.py +759 -0
  32. agentworks/ssh_config.py +255 -0
  33. agentworks/vm_hosts/__init__.py +0 -0
  34. agentworks/vm_hosts/manager.py +86 -0
  35. agentworks/vms/__init__.py +0 -0
  36. agentworks/vms/backup.py +409 -0
  37. agentworks/vms/base.py +56 -0
  38. agentworks/vms/bootstrap_script.py +185 -0
  39. agentworks/vms/cloud_init.py +55 -0
  40. agentworks/vms/initializer.py +1523 -0
  41. agentworks/vms/manager.py +1122 -0
  42. agentworks/vms/provisioners/__init__.py +0 -0
  43. agentworks/vms/provisioners/azure.py +602 -0
  44. agentworks/vms/provisioners/lima.py +295 -0
  45. agentworks/vms/provisioners/proxmox.py +279 -0
  46. agentworks/vms/provisioners/proxmox_api.py +261 -0
  47. agentworks/vms/provisioners/wsl2.py +340 -0
  48. agentworks/vms/templates.py +152 -0
  49. agentworks/workspaces/__init__.py +0 -0
  50. agentworks/workspaces/backends/__init__.py +0 -0
  51. agentworks/workspaces/backends/local.py +119 -0
  52. agentworks/workspaces/backends/vm.py +175 -0
  53. agentworks/workspaces/manager.py +1080 -0
  54. agentworks/workspaces/templates.py +76 -0
  55. agentworks/workspaces/tmuxinator.py +80 -0
  56. agentworks_cli-0.2.1.dist-info/METADATA +635 -0
  57. agentworks_cli-0.2.1.dist-info/RECORD +59 -0
  58. agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
  59. agentworks_cli-0.2.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1080 @@
1
+ """Workspace lifecycle orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import subprocess
7
+ from typing import TYPE_CHECKING
8
+
9
+ from agentworks import output
10
+ from agentworks.config import validate_name
11
+ from agentworks.db import InitStatus, VMStatus
12
+ from agentworks.workspaces.templates import ResolvedTemplate, resolve_template
13
+
14
+ if TYPE_CHECKING:
15
+ from agentworks.config import Config
16
+ from agentworks.db import Database, VMRow, WorkspaceRow
17
+
18
+
19
+ def create_workspace(
20
+ db: Database,
21
+ config: Config,
22
+ *,
23
+ name: str,
24
+ vm_name: str | None = None,
25
+ local: bool = False,
26
+ template_name: str | None = None,
27
+ open_vscode: bool = False,
28
+ ) -> None:
29
+ """Create a workspace on a VM or locally."""
30
+ ws_name = name
31
+ validate_name(ws_name)
32
+
33
+ if db.get_workspace(ws_name) is not None:
34
+ raise output.WorkspaceError(f"workspace '{ws_name}' already exists")
35
+
36
+ # Resolve template
37
+ template = resolve_template(config, template_name)
38
+
39
+ if local:
40
+ _create_local(db, config, ws_name, template_name=template.name, template=template, open_vscode=open_vscode)
41
+ else:
42
+ _create_vm(
43
+ db,
44
+ config,
45
+ ws_name,
46
+ vm_name=vm_name,
47
+ template_name=template.name,
48
+ template=template,
49
+ open_vscode=open_vscode,
50
+ )
51
+
52
+
53
+ def _create_local(
54
+ db: Database,
55
+ config: Config,
56
+ ws_name: str,
57
+ *,
58
+ template_name: str,
59
+ template: ResolvedTemplate,
60
+ open_vscode: bool,
61
+ ) -> None:
62
+ from agentworks.workspaces.backends.local import create_local_workspace, delete_local_workspace
63
+
64
+ workspace_path: str | None = None
65
+ try:
66
+ output.info(f"Creating local workspace '{ws_name}' (template: {template_name})...")
67
+ workspace_path = create_local_workspace(config, ws_name, template)
68
+
69
+ db.insert_workspace(ws_name, ws_type="local", workspace_path=workspace_path, template=template_name)
70
+ except output.AgentworksError:
71
+ if workspace_path:
72
+ delete_local_workspace(ws_name, workspace_path)
73
+ raise
74
+ except Exception as e:
75
+ if workspace_path:
76
+ delete_local_workspace(ws_name, workspace_path)
77
+ raise output.WorkspaceError(f"creating workspace: {e}") from None
78
+
79
+ if open_vscode:
80
+ subprocess.run(["code", workspace_path], check=False)
81
+
82
+ output.info(f"Workspace '{ws_name}' created at {workspace_path}")
83
+
84
+
85
+ def _create_vm(
86
+ db: Database,
87
+ config: Config,
88
+ ws_name: str,
89
+ *,
90
+ vm_name: str | None,
91
+ template_name: str,
92
+ template: ResolvedTemplate,
93
+ open_vscode: bool,
94
+ ) -> None:
95
+ from agentworks.workspaces.backends.vm import (
96
+ create_vm_workspace,
97
+ delete_vm_workspace,
98
+ generate_vscode_workspace,
99
+ )
100
+
101
+ vm = _resolve_vm(db, vm_name)
102
+
103
+ _guard_vm_status(vm)
104
+
105
+ _ensure_vm_running(db, config, vm)
106
+
107
+ workspace_path: str | None = None
108
+ vscode_path: str | None = None
109
+
110
+ def _cleanup() -> None:
111
+ if workspace_path:
112
+ delete_vm_workspace(vm, config, ws_name, workspace_path)
113
+ if vscode_path:
114
+ from pathlib import Path
115
+
116
+ Path(vscode_path).unlink(missing_ok=True)
117
+
118
+ from agentworks.ssh import SSHLogger
119
+
120
+ ssh_logger = SSHLogger(vm.name, "workspace-create")
121
+
122
+ try:
123
+ output.info(f"Creating workspace '{ws_name}' on VM '{vm.name}' (template: {template_name})...")
124
+ workspace_path = create_vm_workspace(vm, config, ws_name, template, logger=ssh_logger)
125
+
126
+ vscode_path = generate_vscode_workspace(vm, config, ws_name, workspace_path)
127
+ output.detail(f"VS Code workspace: {vscode_path}")
128
+
129
+ db.insert_workspace(
130
+ ws_name,
131
+ ws_type="vm",
132
+ workspace_path=workspace_path,
133
+ vm_name=vm.name,
134
+ template=template_name,
135
+ )
136
+ except output.AgentworksError:
137
+ ssh_logger.close()
138
+ _cleanup()
139
+ raise
140
+ except Exception as e:
141
+ ssh_logger.close()
142
+ _cleanup()
143
+ raise output.WorkspaceError(f"creating workspace: {e}\nSSH log: {ssh_logger.path}") from None
144
+
145
+ # Add grant_all agents to the new workspace group
146
+ grant_all_agents = db.list_agents_on_vm_with_grant_all(vm.name)
147
+ if grant_all_agents:
148
+ from agentworks.agents.manager import _add_to_workspace_group
149
+
150
+ for agent in grant_all_agents:
151
+ _add_to_workspace_group(vm, config, agent.linux_user, ws_name, logger=ssh_logger)
152
+ db.insert_agent_grant(agent.name, ws_name, "explicit")
153
+ output.detail(f"Added {len(grant_all_agents)} grant-all agent(s) to workspace")
154
+
155
+ ssh_logger.close()
156
+
157
+ if open_vscode:
158
+ subprocess.run(["code", vscode_path], check=False)
159
+
160
+ output.info(f"Workspace '{ws_name}' created")
161
+
162
+
163
+ def shell_workspace(
164
+ db: Database,
165
+ config: Config,
166
+ name: str,
167
+ ) -> None:
168
+ """Open a plain shell into a workspace."""
169
+ ws = db.get_workspace(name)
170
+ if ws is None:
171
+ raise output.WorkspaceError(f"workspace '{name}' not found")
172
+
173
+ if ws.type == "local":
174
+ from agentworks.workspaces.backends.local import shell_local_workspace
175
+
176
+ db.update_workspace_last_seen(name)
177
+ shell_local_workspace(ws.workspace_path)
178
+ elif ws.type == "vm":
179
+ vm = db.get_vm(ws.vm_name) # type: ignore[arg-type]
180
+ if vm is None:
181
+ raise output.VMError(f"VM '{ws.vm_name}' not found")
182
+
183
+ _guard_vm_status(vm)
184
+ _ensure_vm_running(db, config, vm)
185
+ db.update_workspace_last_seen(name)
186
+
187
+ from agentworks.workspaces.backends.vm import shell_vm_workspace
188
+
189
+ shell_vm_workspace(vm, config, ws.workspace_path)
190
+ else:
191
+ raise output.WorkspaceError(f"unknown workspace type '{ws.type}'")
192
+
193
+
194
+ def console_workspace(
195
+ db: Database,
196
+ config: Config,
197
+ name: str,
198
+ *,
199
+ allow_nesting: bool = False,
200
+ recreate: bool = False,
201
+ ) -> None:
202
+ """Open the workspace console (tmuxinator session with sessions)."""
203
+ import os
204
+
205
+ if os.environ.get("TMUX") and not allow_nesting:
206
+ raise output.WorkspaceError(
207
+ "already inside a tmux session.\n"
208
+ "Nesting is not recommended (prefix key conflicts,\n"
209
+ "confusing detach behavior).\n"
210
+ "Pass --allow-nesting to override."
211
+ )
212
+
213
+ ws = db.get_workspace(name)
214
+ if ws is None:
215
+ raise output.WorkspaceError(f"workspace '{name}' not found")
216
+
217
+ if ws.type == "local":
218
+ from agentworks.workspaces.backends.local import console_local_workspace
219
+
220
+ db.update_workspace_last_seen(name)
221
+ console_local_workspace(name, recreate=recreate)
222
+ elif ws.type == "vm":
223
+ vm = db.get_vm(ws.vm_name) # type: ignore[arg-type]
224
+ if vm is None:
225
+ raise output.VMError(f"VM '{ws.vm_name}' not found")
226
+
227
+ _guard_vm_status(vm)
228
+ _ensure_vm_running(db, config, vm)
229
+ db.update_workspace_last_seen(name)
230
+
231
+ from agentworks.workspaces.backends.vm import console_vm_workspace
232
+
233
+ console_vm_workspace(vm, config, name, recreate=recreate)
234
+ else:
235
+ raise output.WorkspaceError(f"unknown workspace type '{ws.type}'")
236
+
237
+
238
+ def describe_workspace(
239
+ db: Database,
240
+ name: str,
241
+ ) -> None:
242
+ """Show workspace details."""
243
+ ws = db.get_workspace(name)
244
+ if ws is None:
245
+ raise output.WorkspaceError(f"workspace '{name}' not found")
246
+
247
+ output.info(f"Name: {ws.name}")
248
+ output.info(f"Type: {ws.type}")
249
+ output.info(f"VM: {ws.vm_name or '-'}")
250
+ output.info(f"Template: {ws.template or 'default'}")
251
+ output.info(f"Path: {ws.workspace_path}")
252
+ output.info(f"Created: {ws.created_at}")
253
+ if ws.last_seen_at:
254
+ output.info(f"Last Seen: {ws.last_seen_at}")
255
+
256
+ # Sessions
257
+ sessions = db.list_sessions(workspace_name=name)
258
+ output.info(f"\nSessions ({len(sessions)}):")
259
+ if sessions:
260
+ for s in sessions:
261
+ mode_label = f"agent: {s.agent_name}" if s.agent_name else "admin"
262
+ output.detail(f"{s.name} [{s.template}] {mode_label}")
263
+ else:
264
+ output.detail("(none)")
265
+
266
+ # Agents with grants (VM workspaces only)
267
+ if ws.type == "vm" and ws.vm_name:
268
+ agents = db.list_agents(vm_name=ws.vm_name)
269
+ granted = [a for a in agents if db.has_any_grant(a.name, name)]
270
+ output.info(f"\nAgents with access ({len(granted)}):")
271
+ if granted:
272
+ for agent in granted:
273
+ output.detail(f"{agent.name} (user: {agent.linux_user})")
274
+ else:
275
+ output.detail("(none)")
276
+
277
+
278
+ def list_workspaces(
279
+ db: Database,
280
+ *,
281
+ vm_name: str | None = None,
282
+ ws_type: str | None = None,
283
+ ) -> None:
284
+ """List workspaces."""
285
+ workspaces = db.list_workspaces(vm_name=vm_name, ws_type=ws_type)
286
+ if not workspaces:
287
+ output.info("No workspaces found.")
288
+ return
289
+
290
+ def _tpl_name(t: str | None) -> str:
291
+ if t is None or t == "(built-in)":
292
+ return "default"
293
+ return t
294
+
295
+ rows = [(ws.name, ws.type, ws.vm_name or "-", _tpl_name(ws.template), ws.created_at) for ws in workspaces]
296
+
297
+ name_w = max(len("NAME"), max(len(r[0]) for r in rows))
298
+ type_w = max(len("TYPE"), max(len(r[1]) for r in rows))
299
+ vm_w = max(len("VM"), max(len(r[2]) for r in rows))
300
+ tpl_w = max(len("TEMPLATE"), max(len(r[3]) for r in rows))
301
+
302
+ header = f"{'NAME':<{name_w}} {'TYPE':<{type_w}} {'VM':<{vm_w}} {'TEMPLATE':<{tpl_w}} CREATED"
303
+ output.info(header)
304
+ output.info("-" * len(header))
305
+ for ws_name, ws_type, vm_name, tpl, created in rows:
306
+ output.info(f"{ws_name:<{name_w}} {ws_type:<{type_w}} {vm_name:<{vm_w}} {tpl:<{tpl_w}} {created}")
307
+
308
+
309
+ def repair_workspace(
310
+ db: Database,
311
+ config: Config,
312
+ name: str,
313
+ ) -> None:
314
+ """Repair workspace infrastructure: group, permissions, ACLs, agent access."""
315
+ from agentworks.agents.manager import AGENT_PREFIX, WS_GROUP_PREFIX
316
+ from agentworks.ssh import SSHError, admin_exec_target
317
+
318
+ ws = db.get_workspace(name)
319
+ if ws is None:
320
+ raise output.WorkspaceError(f"workspace '{name}' not found")
321
+
322
+ if ws.type != "vm":
323
+ raise output.WorkspaceError(f"workspace '{name}' is local, nothing to repair")
324
+
325
+ assert ws.vm_name is not None
326
+ vm = db.get_vm(ws.vm_name)
327
+ if vm is None:
328
+ raise output.VMError(f"VM '{ws.vm_name}' not found")
329
+
330
+ target = admin_exec_target(vm, config)
331
+ ws_group = f"{WS_GROUP_PREFIX}{name}"
332
+ fixes = 0
333
+
334
+ output.info(f"Repairing workspace '{name}' on VM '{vm.name}'...")
335
+
336
+ # 0. Ensure acl package is installed (needed for setfacl)
337
+ try:
338
+ has_setfacl = target.run("which setfacl", sudo=True, check=False)
339
+ if not has_setfacl.ok:
340
+ target.run("apt-get install -y -qq acl", sudo=True, timeout=60)
341
+ output.detail("Fixed: installed acl package")
342
+ fixes += 1
343
+ else:
344
+ output.detail("OK: acl package")
345
+ except SSHError as e:
346
+ output.warn(f"acl package check failed: {e}")
347
+
348
+ # 1. Ensure workspace group exists (with correct naming)
349
+ try:
350
+ # Check for old-style group and rename if needed
351
+ old_group = f"ws-{name}"
352
+ old_exists = target.run(f"getent group {old_group}", sudo=True, check=False)
353
+ new_exists = target.run(f"getent group {ws_group}", sudo=True, check=False)
354
+
355
+ if old_exists.ok and not new_exists.ok:
356
+ target.run(f"groupmod -n {ws_group} {old_group}", sudo=True)
357
+ output.detail(f"Fixed: renamed group {old_group} -> {ws_group}")
358
+ fixes += 1
359
+ elif not new_exists.ok:
360
+ target.run(
361
+ f"sh -c 'getent group {ws_group} >/dev/null 2>&1 || /usr/sbin/groupadd {ws_group}'",
362
+ sudo=True,
363
+ )
364
+ output.detail(f"Fixed: created group {ws_group}")
365
+ fixes += 1
366
+ else:
367
+ output.detail(f"OK: group {ws_group} exists")
368
+ except SSHError as e:
369
+ output.warn(f"group check failed: {e}")
370
+
371
+ # 2. Ensure admin is in the group
372
+ try:
373
+ in_group = target.run(
374
+ f"id -nG {vm.admin_username}",
375
+ sudo=True,
376
+ check=False,
377
+ )
378
+ if in_group.ok and ws_group not in in_group.stdout.split():
379
+ target.run(f"usermod -aG {ws_group} {vm.admin_username}", sudo=True)
380
+ output.detail(f"Fixed: added admin '{vm.admin_username}' to {ws_group}")
381
+ fixes += 1
382
+ else:
383
+ output.detail(f"OK: admin in {ws_group}")
384
+ except SSHError as e:
385
+ output.warn(f"admin group check failed: {e}")
386
+
387
+ # 3. Fix directory permissions (recursive chgrp so ACLs apply correctly)
388
+ try:
389
+ target.run(f"chown -R {vm.admin_username}:{ws_group} {ws.workspace_path}", sudo=True, timeout=120)
390
+ target.run(f"chmod 2770 {ws.workspace_path}", sudo=True)
391
+ # Set SGID on all subdirectories so new files inherit the workspace group.
392
+ # This is critical for atomic-write tools (including Claude Code) that
393
+ # create a temp file and rename it over the original.
394
+ target.run(
395
+ f"find {shlex.quote(ws.workspace_path)} -type d -exec chmod g+s {{}} +",
396
+ sudo=True,
397
+ timeout=120,
398
+ )
399
+ output.detail("OK: directory ownership and permissions")
400
+ except SSHError as e:
401
+ output.warn(f"permission fix failed: {e}")
402
+
403
+ # 4. Fix ACLs
404
+ # Default ACLs only apply to directories; use find to avoid warnings on files.
405
+ # Effective ACLs apply to all entries and should not produce output.
406
+ try:
407
+ target.run(
408
+ f"find {ws.workspace_path} -type d -exec setfacl -d -m g::rwx -m m::rwx {{}} +",
409
+ sudo=True,
410
+ timeout=120,
411
+ )
412
+ target.run(
413
+ f"setfacl -R -m g::rwx -m m::rwx {ws.workspace_path}",
414
+ sudo=True,
415
+ timeout=120,
416
+ )
417
+ output.detail("OK: ACLs")
418
+ except SSHError as e:
419
+ output.warn(f"ACL fix failed: {e}")
420
+
421
+ # 5. Fix parent directory traversal
422
+ try:
423
+ target.run(
424
+ f'sh -c \'p={ws.workspace_path}; while [ "$p" != "/" ]; do chmod a+x "$p"; p=$(dirname "$p"); done\'',
425
+ sudo=True,
426
+ )
427
+ output.detail("OK: parent traversal")
428
+ except SSHError as e:
429
+ output.warn(f"parent traversal fix failed: {e}")
430
+
431
+ # 6. Reconcile agent group membership
432
+ # Get agents that SHOULD be in the group (have any grant)
433
+ granted_agents = set()
434
+ all_agents = db.list_agents(vm_name=vm.name)
435
+ for agent in all_agents:
436
+ if db.has_any_grant(agent.name, name):
437
+ granted_agents.add(agent.linux_user)
438
+
439
+ # Get agents that ARE in the group (only agt-- prefixed users)
440
+ try:
441
+ group_info = target.run(f"getent group {ws_group}", sudo=True, check=False)
442
+ current_members: set[str] = set()
443
+ if group_info.ok and ":" in group_info.stdout:
444
+ members_str = group_info.stdout.strip().split(":")[-1]
445
+ if members_str:
446
+ current_members = {m for m in members_str.split(",") if m.startswith(AGENT_PREFIX)}
447
+
448
+ # Add missing agents
449
+ to_add = granted_agents - current_members
450
+ for user in sorted(to_add):
451
+ target.run(f"usermod -aG {ws_group} {user}", sudo=True)
452
+ output.detail(f"Fixed: added {user} to {ws_group}")
453
+ fixes += 1
454
+
455
+ # Remove agents that shouldn't be there
456
+ to_remove = current_members - granted_agents
457
+ for user in sorted(to_remove):
458
+ target.run(f"gpasswd -d {user} {ws_group}", sudo=True, check=False)
459
+ output.detail(f"Fixed: removed {user} from {ws_group}")
460
+ fixes += 1
461
+
462
+ if not to_add and not to_remove:
463
+ output.detail(f"OK: agent group membership ({len(current_members)} agent(s))")
464
+ except SSHError as e:
465
+ output.warn(f"agent membership check failed: {e}")
466
+
467
+ if fixes > 0:
468
+ output.info(f"\nRepaired {fixes} issue(s)")
469
+ else:
470
+ output.info("\nNo issues found")
471
+
472
+
473
+ def rehome_workspace(
474
+ db: Database,
475
+ config: Config,
476
+ name: str,
477
+ *,
478
+ target_path: str | None = None,
479
+ remove_old: bool = False,
480
+ yes: bool = False,
481
+ ) -> None:
482
+ """Move a workspace to a new directory path."""
483
+ ws = db.get_workspace(name)
484
+ if ws is None:
485
+ raise output.WorkspaceError(f"workspace '{name}' not found")
486
+
487
+ # Determine target path
488
+ if target_path is not None:
489
+ new_path = target_path
490
+ elif ws.type == "vm":
491
+ new_path = f"{config.paths.vm_workspaces}/{name}"
492
+ else:
493
+ new_path = str(config.paths.local_workspaces / name)
494
+
495
+ old_path = ws.workspace_path
496
+
497
+ if old_path == new_path:
498
+ output.info(f"Workspace '{name}' is already at {new_path}")
499
+ return
500
+
501
+ # Safety: detect overlapping paths
502
+ old_norm = old_path.rstrip("/") + "/"
503
+ new_norm = new_path.rstrip("/") + "/"
504
+ if new_norm.startswith(old_norm) or old_norm.startswith(new_norm):
505
+ raise output.WorkspaceError("source and target paths overlap")
506
+
507
+ # Block unless all sessions are STOPPED
508
+ from agentworks.db import PID_STOPPED, SessionStatus
509
+ from agentworks.sessions.manager import batch_check_all_sessions, ensure_pids_batch
510
+
511
+ sessions = db.list_sessions(workspace_name=name)
512
+ if sessions:
513
+ try:
514
+ sessions = ensure_pids_batch(sessions, db=db, config=config)
515
+ status_map = batch_check_all_sessions(sessions, db=db, config=config)
516
+ except Exception as exc:
517
+ raise output.WorkspaceError(
518
+ f"cannot verify session status for workspace '{name}' (VM may be unreachable): {exc}"
519
+ ) from exc
520
+ not_stopped = [
521
+ s for s in sessions
522
+ if s.pid != PID_STOPPED and status_map.get(s.name, SessionStatus.UNKNOWN) != SessionStatus.STOPPED
523
+ ]
524
+ if not_stopped:
525
+ names = ", ".join(s.name for s in not_stopped)
526
+ raise output.WorkspaceError(
527
+ f"workspace '{name}' has {len(not_stopped)} non-stopped session(s) ({names}). "
528
+ "Stop or delete them first."
529
+ )
530
+
531
+ if ws.type == "vm":
532
+ _rehome_vm(db, config, ws, new_path, remove_old=remove_old, yes=yes)
533
+ elif ws.type == "local":
534
+ _rehome_local(db, config, ws, new_path, remove_old=remove_old, yes=yes)
535
+ else:
536
+ raise output.WorkspaceError(f"unknown workspace type '{ws.type}'")
537
+
538
+
539
+ def _rehome_vm(
540
+ db: Database,
541
+ config: Config,
542
+ ws: WorkspaceRow,
543
+ new_path: str,
544
+ *,
545
+ remove_old: bool,
546
+ yes: bool,
547
+ ) -> None:
548
+ """Rehome a VM workspace."""
549
+
550
+ from agentworks.agents.manager import WS_GROUP_PREFIX
551
+ from agentworks.ssh import SSHError, SSHLogger, admin_exec_target
552
+ from agentworks.workspaces.backends.vm import generate_vscode_workspace
553
+
554
+ ws_name = ws.name
555
+ old_path = ws.workspace_path
556
+ assert ws.vm_name is not None
557
+ vm_name = ws.vm_name
558
+
559
+ vm = db.get_vm(vm_name)
560
+ if vm is None:
561
+ raise output.VMError(f"VM '{vm_name}' not found")
562
+
563
+ _guard_vm_status(vm)
564
+ _ensure_vm_running(db, config, vm)
565
+
566
+ target = admin_exec_target(vm, config)
567
+
568
+ # Verify source exists
569
+ src_check = target.run(f"test -d {old_path}", check=False, timeout=10)
570
+ if not src_check.ok:
571
+ raise output.WorkspaceError(f"source directory {old_path} does not exist on VM")
572
+
573
+ # Verify target does not exist
574
+ dst_check = target.run(f"test -d {new_path}", check=False, timeout=10)
575
+ if dst_check.ok:
576
+ raise output.WorkspaceError(f"target directory {new_path} already exists on VM")
577
+
578
+ if not yes:
579
+ output.info(f"Rehome workspace '{ws_name}':")
580
+ output.detail(f"From: {old_path}")
581
+ output.detail(f"To: {new_path}")
582
+ if remove_old:
583
+ output.detail("Old directory will be REMOVED after copy")
584
+ else:
585
+ output.detail("Old directory will be LEFT IN PLACE")
586
+ if not output.confirm("Proceed?"):
587
+ raise output.UserAbort("rehome cancelled")
588
+
589
+ ssh_logger = SSHLogger(vm.name, "workspace-rehome")
590
+ target = admin_exec_target(vm, config, logger=ssh_logger)
591
+ ws_group = f"{WS_GROUP_PREFIX}{ws_name}"
592
+
593
+ try:
594
+ # Create target directory as root and chown to admin so rsync can write
595
+ target.run(f"mkdir -p {new_path}", sudo=True)
596
+ target.run(f"chown {vm.admin_username} {new_path}", sudo=True)
597
+
598
+ # Copy with rsync (fall back to cp -a)
599
+ output.info("Copying workspace...")
600
+ has_rsync = target.run("which rsync", check=False, timeout=10)
601
+ if has_rsync.ok:
602
+ target.run(f"rsync -a {old_path}/ {new_path}/", timeout=600)
603
+ else:
604
+ target.run(f"cp -a {old_path}/. {new_path}/", sudo=True, timeout=600)
605
+
606
+ # Verify copy succeeded
607
+ verify = target.run(f"test -d {new_path}", check=False, timeout=10)
608
+ if not verify.ok:
609
+ ssh_logger.close()
610
+ raise output.WorkspaceError(
611
+ f"copy verification failed, target directory not found\nSSH log: {ssh_logger.path}"
612
+ )
613
+
614
+ # Fix ownership, permissions, and ACLs on the new path
615
+ output.info("Setting permissions...")
616
+ target.run(f"chown {vm.admin_username}:{ws_group} {new_path}", sudo=True)
617
+ target.run(f"chmod 2770 {new_path}", sudo=True)
618
+ sgid_cmd = f"find {shlex.quote(new_path)} -type d -exec chmod g+s {{}} +"
619
+ target.run(sgid_cmd, sudo=True, timeout=120)
620
+ try:
621
+ target.run(
622
+ f"find {new_path} -type d -exec setfacl -d -m g::rwx -m m::rwx {{}} +",
623
+ sudo=True,
624
+ timeout=120,
625
+ )
626
+ target.run(
627
+ f"setfacl -R -m g::rwx -m m::rwx {new_path}",
628
+ sudo=True,
629
+ timeout=120,
630
+ )
631
+ except SSHError as e:
632
+ output.warn(f"ACL setup failed: {e}")
633
+
634
+ # Fix parent directory traversal
635
+ target.run(
636
+ f'sh -c \'p={new_path}; while [ "$p" != "/" ]; do chmod a+x "$p"; p=$(dirname "$p"); done\'',
637
+ sudo=True,
638
+ )
639
+
640
+ # Regenerate tmuxinator config at new path
641
+ from agentworks.workspaces.tmuxinator import console_session_name, generate_config
642
+
643
+ tmux_config = generate_config(ws_name, new_path)
644
+ target.write_file(f"{new_path}/.tmuxinator.yml", tmux_config)
645
+ session = console_session_name(ws_name)
646
+ target.run("mkdir -p ~/.config/tmuxinator", timeout=10)
647
+ target.run(
648
+ f"ln -sf {new_path}/.tmuxinator.yml ~/.config/tmuxinator/{session}.yml",
649
+ timeout=10,
650
+ )
651
+
652
+ # Update database
653
+ db.update_workspace_path(ws_name, new_path)
654
+ output.info(f"Database updated: workspace_path = {new_path}")
655
+
656
+ # Regenerate VS Code workspace file
657
+ vscode_path = generate_vscode_workspace(vm, config, ws_name, new_path)
658
+ output.info(f"VS Code workspace updated: {vscode_path}")
659
+
660
+ # Handle old directory
661
+ if remove_old:
662
+ output.info(f"Removing old directory {old_path}...")
663
+ target.run(f"rm -rf {old_path}", sudo=True, timeout=60)
664
+ output.info("Old directory removed")
665
+ else:
666
+ output.info(f"\nOld directory left in place at {old_path}")
667
+ output.info("Remove it manually when ready, or re-run with --remove-old")
668
+
669
+ except output.AgentworksError:
670
+ ssh_logger.close()
671
+ raise
672
+ except Exception as e:
673
+ ssh_logger.close()
674
+ raise output.WorkspaceError(
675
+ f"during rehome: {e}\n"
676
+ f"SSH log: {ssh_logger.path}\n"
677
+ "The database was NOT updated. The workspace is still at the original path."
678
+ ) from None
679
+
680
+ ssh_logger.close()
681
+ output.info(f"\nWorkspace '{ws_name}' rehomed to {new_path}")
682
+
683
+
684
+ def _rehome_local(
685
+ db: Database,
686
+ config: Config,
687
+ ws: WorkspaceRow,
688
+ new_path: str,
689
+ *,
690
+ remove_old: bool,
691
+ yes: bool,
692
+ ) -> None:
693
+ """Rehome a local workspace."""
694
+ import shutil
695
+ from pathlib import Path
696
+
697
+
698
+ ws_name = ws.name
699
+ old_path = ws.workspace_path
700
+
701
+ old_dir = Path(old_path)
702
+ new_dir = Path(new_path)
703
+
704
+ if not old_dir.exists():
705
+ raise output.WorkspaceError(f"source directory {old_path} does not exist")
706
+
707
+ if new_dir.exists():
708
+ raise output.WorkspaceError(f"target directory {new_path} already exists")
709
+
710
+ if not yes:
711
+ output.info(f"Rehome workspace '{ws_name}':")
712
+ output.detail(f"From: {old_path}")
713
+ output.detail(f"To: {new_path}")
714
+ if remove_old:
715
+ output.detail("Old directory will be REMOVED after copy")
716
+ else:
717
+ output.detail("Old directory will be LEFT IN PLACE")
718
+ if not output.confirm("Proceed?"):
719
+ raise output.UserAbort("rehome cancelled")
720
+
721
+ # Copy
722
+ output.info("Copying workspace...")
723
+ new_dir.parent.mkdir(parents=True, exist_ok=True)
724
+ shutil.copytree(old_path, new_path, symlinks=True)
725
+
726
+ # Verify
727
+ if not new_dir.exists():
728
+ raise output.WorkspaceError("copy verification failed, target directory not found")
729
+
730
+ # Regenerate tmuxinator config at new path
731
+ from agentworks.workspaces.tmuxinator import console_session_name, generate_config
732
+
733
+ tmux_file = new_dir / ".tmuxinator.yml"
734
+ if tmux_file.exists() or (old_dir / ".tmuxinator.yml").exists():
735
+ tmux_config = generate_config(ws_name, new_path)
736
+ tmux_file.write_text(tmux_config)
737
+ session = console_session_name(ws_name)
738
+ tmux_config_dir = Path.home() / ".config" / "tmuxinator"
739
+ tmux_config_dir.mkdir(parents=True, exist_ok=True)
740
+ link = tmux_config_dir / f"{session}.yml"
741
+ link.unlink(missing_ok=True)
742
+ link.symlink_to(tmux_file)
743
+
744
+ # Update database
745
+ db.update_workspace_path(ws_name, new_path)
746
+ output.info(f"Database updated: workspace_path = {new_path}")
747
+
748
+ # Handle old directory
749
+ if remove_old:
750
+ output.info(f"Removing old directory {old_path}...")
751
+ shutil.rmtree(old_path)
752
+ output.info("Old directory removed")
753
+ else:
754
+ output.info(f"\nOld directory left in place at {old_path}")
755
+ output.info("Remove it manually when ready, or re-run with --remove-old")
756
+
757
+ output.info(f"\nWorkspace '{ws_name}' rehomed to {new_path}")
758
+
759
+
760
+ def delete_workspace(
761
+ db: Database,
762
+ config: Config,
763
+ name: str,
764
+ *,
765
+ force: bool = False,
766
+ yes: bool = False,
767
+ ) -> None:
768
+ """Delete a workspace."""
769
+
770
+ ws = db.get_workspace(name)
771
+ if ws is None:
772
+ raise output.WorkspaceError(f"workspace '{name}' not found")
773
+
774
+ # Check for sessions
775
+ session_count = len(db.list_sessions(workspace_name=name))
776
+ if session_count > 0 and not force:
777
+ raise output.WorkspaceError(
778
+ f"workspace '{name}' has {session_count} session(s). Delete them first, or use --force."
779
+ )
780
+
781
+ if not yes:
782
+ msg = f"Delete workspace '{name}'?"
783
+ if session_count > 0:
784
+ msg += f" ({session_count} session(s) will also be deleted)"
785
+ if not output.confirm(msg):
786
+ raise output.UserAbort("delete cancelled")
787
+
788
+ # Create SSH logger for VM operations
789
+ ssh_logger = None
790
+ if ws.type == "vm" and ws.vm_name:
791
+ from agentworks.ssh import SSHLogger
792
+
793
+ ssh_logger = SSHLogger(ws.vm_name, "workspace-delete")
794
+
795
+ # Kill running sessions (status-aware) and delete session records
796
+ if ws.type == "vm" and ws.vm_name:
797
+ vm = db.get_vm(ws.vm_name)
798
+ if vm is not None and vm.tailscale_host is not None:
799
+ from agentworks.db import SessionStatus
800
+ from agentworks.sessions.manager import (
801
+ check_session_status,
802
+ ensure_pids_batch,
803
+ )
804
+ from agentworks.sessions.tmux import force_kill_tmux_server, kill_session
805
+ from agentworks.ssh import admin_exec_target
806
+
807
+ target = admin_exec_target(vm, config, logger=ssh_logger)
808
+ sessions = db.list_sessions(workspace_name=name)
809
+ sessions = ensure_pids_batch(sessions, db=db, config=config)
810
+ unstoppable: list[str] = []
811
+ for session in sessions:
812
+ status = check_session_status(session, target=target)
813
+ if status == SessionStatus.OK:
814
+ if not kill_session(session.name, run_command=target.run, socket_path=session.socket_path):
815
+ # Race: session may have exited between check and kill. Recheck.
816
+ recheck = check_session_status(session, target=target)
817
+ if recheck != SessionStatus.STOPPED:
818
+ unstoppable.append(session.name)
819
+ continue
820
+ elif status == SessionStatus.BROKEN:
821
+ if session.pid and session.pid > 0 and force_kill_tmux_server(
822
+ session.pid, target=target, socket_path=session.socket_path,
823
+ ):
824
+ pass # killed successfully
825
+ else:
826
+ unstoppable.append(session.name)
827
+ elif status == SessionStatus.UNKNOWN:
828
+ unstoppable.append(session.name)
829
+ if unstoppable:
830
+ raise output.WorkspaceError(
831
+ f"cannot delete workspace '{name}': {len(unstoppable)} session(s) could not be stopped "
832
+ f"({', '.join(unstoppable)}). Resolve manually before retrying."
833
+ )
834
+ db.delete_sessions_for_workspace(name)
835
+
836
+ # Revoke agent workspace grants (agents are VM-scoped, not deleted with workspaces)
837
+ if ws.type == "vm" and ws.vm_name:
838
+ vm_for_grants = db.get_vm(ws.vm_name)
839
+ if vm_for_grants:
840
+ from agentworks.agents.manager import revoke_workspace_grants
841
+
842
+ revoke_workspace_grants(db, config, name, vm_for_grants)
843
+
844
+ if ws.type == "local":
845
+ from agentworks.workspaces.backends.local import delete_local_workspace
846
+
847
+ delete_local_workspace(name, ws.workspace_path)
848
+ elif ws.type == "vm" and ws.vm_name:
849
+ vm = db.get_vm(ws.vm_name)
850
+ if vm is not None:
851
+ from agentworks.workspaces.backends.vm import delete_vm_workspace
852
+
853
+ delete_vm_workspace(vm, config, name, ws.workspace_path, logger=ssh_logger)
854
+
855
+ if ssh_logger is not None:
856
+ ssh_logger.close()
857
+
858
+ # Remove .code-workspace file
859
+ vscode_path = config.paths.vscode_workspaces / f"{name}.code-workspace"
860
+ vscode_path.unlink(missing_ok=True)
861
+
862
+ db.delete_workspace(name)
863
+ output.info(f"Workspace '{name}' deleted")
864
+
865
+
866
+ def copy_workspace(
867
+ db: Database,
868
+ config: Config,
869
+ source_name: str,
870
+ *,
871
+ dest_name: str,
872
+ vm_name: str | None = None,
873
+ local: bool = False,
874
+ ) -> None:
875
+ """Copy a workspace to a new location."""
876
+ import tempfile
877
+ from pathlib import Path
878
+
879
+ from agentworks.ssh import admin_exec_target
880
+
881
+ validate_name(dest_name)
882
+
883
+ src_ws = db.get_workspace(source_name)
884
+ if src_ws is None:
885
+ raise output.WorkspaceError(f"workspace '{source_name}' not found")
886
+
887
+ if db.get_workspace(dest_name) is not None:
888
+ raise output.WorkspaceError(f"workspace '{dest_name}' already exists")
889
+
890
+ # Create a temp file for the archive
891
+ with tempfile.NamedTemporaryFile(suffix=".tgz", delete=False) as tmp:
892
+ tmp_path = Path(tmp.name)
893
+
894
+ try:
895
+ # --- Pack from source ---
896
+ if src_ws.type == "local":
897
+ output.info(f"Packing workspace '{source_name}'...")
898
+ result = subprocess.run(
899
+ ["tar", "czf", str(tmp_path), "-C", src_ws.workspace_path, "."],
900
+ capture_output=True,
901
+ text=True,
902
+ encoding="utf-8",
903
+ errors="replace",
904
+ )
905
+ if result.returncode != 0:
906
+ raise output.WorkspaceError(f"tar failed: {result.stderr.strip()}")
907
+ elif src_ws.type == "vm":
908
+ src_vm = db.get_vm(src_ws.vm_name) # type: ignore[arg-type]
909
+ if src_vm is None:
910
+ raise output.VMError(f"VM '{src_ws.vm_name}' not found")
911
+ _guard_vm_status(src_vm)
912
+ _ensure_vm_running(db, config, src_vm)
913
+ if src_vm.tailscale_host is None:
914
+ raise output.VMError(f"VM '{src_vm.name}' has no Tailscale address")
915
+
916
+ src_exec = admin_exec_target(src_vm, config)
917
+ assert src_exec.ssh is not None
918
+ src_ssh = src_exec.ssh
919
+ output.info(f"Packing workspace '{source_name}' from VM '{src_vm.name}'...")
920
+
921
+ # Stream tar from VM to local temp file
922
+ ssh_args = ["ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes"]
923
+ if src_ssh.identity_file is not None:
924
+ ssh_args.extend(["-i", str(src_ssh.identity_file)])
925
+ ssh_args.append(f"{src_ssh.user}@{src_ssh.host}")
926
+ ssh_args.append(f"tar czf - -C {src_ws.workspace_path} .")
927
+
928
+ with open(tmp_path, "wb") as f:
929
+ proc = subprocess.run(ssh_args, stdout=f, stderr=subprocess.PIPE)
930
+ if proc.returncode != 0:
931
+ stderr = proc.stderr.decode() if proc.stderr else ""
932
+ raise output.WorkspaceError(f"pack failed: {stderr.strip()}")
933
+ else:
934
+ raise output.WorkspaceError(f"unknown workspace type '{src_ws.type}'")
935
+
936
+ # --- Unpack to destination ---
937
+ if local:
938
+ workspace_path = str(config.paths.local_workspaces / dest_name)
939
+ Path(workspace_path).mkdir(parents=True, exist_ok=True)
940
+
941
+ output.info(f"Unpacking to local workspace '{dest_name}'...")
942
+ result = subprocess.run(
943
+ ["tar", "xzf", str(tmp_path), "-C", workspace_path],
944
+ capture_output=True,
945
+ text=True,
946
+ encoding="utf-8",
947
+ errors="replace",
948
+ )
949
+ if result.returncode != 0:
950
+ raise output.WorkspaceError(f"tar failed: {result.stderr.strip()}")
951
+
952
+ db.insert_workspace(
953
+ dest_name,
954
+ ws_type="local",
955
+ workspace_path=workspace_path,
956
+ template="copied",
957
+ )
958
+ else:
959
+ from agentworks.ssh import SSHLogger
960
+
961
+ dest_vm = _resolve_vm(db, vm_name)
962
+ _guard_vm_status(dest_vm)
963
+ _ensure_vm_running(db, config, dest_vm)
964
+ if dest_vm.tailscale_host is None:
965
+ raise output.VMError(f"VM '{dest_vm.name}' has no Tailscale address")
966
+
967
+ lg = SSHLogger(dest_vm.name, "workspace-copy")
968
+ dest_target = admin_exec_target(dest_vm, config, logger=lg)
969
+ workspace_path = f"{config.paths.vm_workspaces}/{dest_name}"
970
+
971
+ ws_group = f"ws--{dest_name}"
972
+
973
+ output.info(f"Unpacking to workspace '{dest_name}' on VM '{dest_vm.name}'...")
974
+
975
+ # Set up group, directory, and permissions (same as create_vm_workspace)
976
+ dest_target.run(
977
+ f"sh -c 'getent group {ws_group} >/dev/null 2>&1 || /usr/sbin/groupadd {ws_group}'",
978
+ sudo=True,
979
+ )
980
+ dest_target.run(f"usermod -aG {ws_group} {dest_vm.admin_username}", sudo=True)
981
+ dest_target.run(f"mkdir -p {workspace_path}", sudo=True, timeout=10)
982
+ dest_target.run(f"chown {dest_vm.admin_username}:{ws_group} {workspace_path}", sudo=True)
983
+ dest_target.run(f"chmod 2770 {workspace_path}", sudo=True)
984
+ dest_target.run(f"setfacl -d -m g::rwx -m m::rwx {workspace_path}", sudo=True)
985
+
986
+ # Unpack archive and fix ownership
987
+ remote_tmp = f"/tmp/{dest_name}-copy.tgz"
988
+ dest_target.copy_to(tmp_path, remote_tmp, timeout=300)
989
+ dest_target.run(f"tar xzf {remote_tmp} -C {workspace_path}", sudo=True, timeout=120)
990
+ dest_target.run(f"rm -f {remote_tmp}", check=False, timeout=10)
991
+ dest_target.run(
992
+ f"chown -R {dest_vm.admin_username}:{ws_group} {workspace_path}",
993
+ sudo=True,
994
+ timeout=60,
995
+ )
996
+ dest_target.run(
997
+ f"find {shlex.quote(workspace_path)} -type d -exec chmod g+s {{}} +",
998
+ sudo=True,
999
+ timeout=120,
1000
+ )
1001
+
1002
+ db.insert_workspace(
1003
+ dest_name,
1004
+ ws_type="vm",
1005
+ vm_name=dest_vm.name,
1006
+ workspace_path=workspace_path,
1007
+ template="copied",
1008
+ )
1009
+
1010
+ # Generate tmuxinator config and VS Code workspace
1011
+ from agentworks.workspaces.backends.vm import generate_vscode_workspace
1012
+ from agentworks.workspaces.tmuxinator import console_session_name, generate_config
1013
+
1014
+ tmux_config = generate_config(dest_name, workspace_path)
1015
+ dest_target.write_file(f"{workspace_path}/.tmuxinator.yml", tmux_config)
1016
+ session = console_session_name(dest_name)
1017
+ dest_target.run("mkdir -p ~/.config/tmuxinator", timeout=10)
1018
+ dest_target.run(
1019
+ f"ln -sf {workspace_path}/.tmuxinator.yml ~/.config/tmuxinator/{session}.yml",
1020
+ timeout=10,
1021
+ )
1022
+ vscode_path = generate_vscode_workspace(dest_vm, config, dest_name, workspace_path)
1023
+ output.detail(f"VS Code workspace: {vscode_path}")
1024
+ lg.close()
1025
+ finally:
1026
+ tmp_path.unlink(missing_ok=True)
1027
+
1028
+ output.info(f"Workspace '{source_name}' copied to '{dest_name}'")
1029
+
1030
+
1031
+ def _guard_vm_status(vm: VMRow) -> None:
1032
+ """Block operations on VMs that are not usable (failed or in-progress)."""
1033
+ usable = {InitStatus.COMPLETE.value, InitStatus.PARTIAL.value}
1034
+ if vm.init_status not in usable:
1035
+ if vm.init_status == InitStatus.FAILED.value:
1036
+ raise output.VMError(
1037
+ f"VM '{vm.name}' is in 'failed' state. Run 'vm delete' and recreate."
1038
+ )
1039
+ else:
1040
+ raise output.VMError(
1041
+ f"VM '{vm.name}' initialization is not complete (status: {vm.init_status})."
1042
+ )
1043
+
1044
+
1045
+ def _resolve_vm(db: Database, vm_name: str | None) -> VMRow:
1046
+ """Resolve which VM to use: explicit, auto-select if 1, or error."""
1047
+ if vm_name is not None:
1048
+ vm = db.get_vm(vm_name)
1049
+ if vm is None:
1050
+ raise output.VMError(f"VM '{vm_name}' not found")
1051
+ return vm
1052
+
1053
+ vms = db.list_vms()
1054
+ usable_statuses = {InitStatus.COMPLETE.value, InitStatus.PARTIAL.value}
1055
+ usable_vms = [v for v in vms if v.init_status in usable_statuses]
1056
+
1057
+ if len(usable_vms) == 0:
1058
+ raise output.VMError("no VMs available. Create one with 'agentworks vm create'.")
1059
+
1060
+ if len(usable_vms) == 1:
1061
+ output.info(f"Using VM '{usable_vms[0].name}'")
1062
+ return usable_vms[0]
1063
+
1064
+ options = [f"{v.name} ({v.platform})" for v in usable_vms]
1065
+ idx = output.choose("Select a VM:", options)
1066
+ return usable_vms[idx]
1067
+
1068
+
1069
+ def _ensure_vm_running(db: Database, config: Config, vm: VMRow) -> None:
1070
+ """Auto-start a stopped/deallocated VM and verify Tailscale connectivity."""
1071
+ from agentworks.vms.manager import _ensure_tailscale, _get_provisioner_for_vm
1072
+
1073
+ provisioner = _get_provisioner_for_vm(db, vm)
1074
+ status = provisioner.status(vm)
1075
+
1076
+ if status in (VMStatus.STOPPED, VMStatus.DEALLOCATED):
1077
+ output.info(f"VM '{vm.name}' is {status.value}. Starting...")
1078
+ provisioner.start(vm)
1079
+ output.info(f"VM '{vm.name}' started")
1080
+ _ensure_tailscale(db, config, vm, provisioner)