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.
- agentworks/__init__.py +1 -0
- agentworks/agents/__init__.py +0 -0
- agentworks/agents/manager.py +1095 -0
- agentworks/agents/templates.py +145 -0
- agentworks/catalog.py +264 -0
- agentworks/catalog.toml +131 -0
- agentworks/cli.py +1462 -0
- agentworks/completions/__init__.py +33 -0
- agentworks/completions/bash.py +179 -0
- agentworks/completions/install.py +122 -0
- agentworks/completions/powershell.py +270 -0
- agentworks/completions/spec.py +216 -0
- agentworks/completions/zsh.py +256 -0
- agentworks/config.py +894 -0
- agentworks/db.py +1083 -0
- agentworks/doctor.py +430 -0
- agentworks/git_credentials/__init__.py +0 -0
- agentworks/git_credentials/azdo.py +29 -0
- agentworks/git_credentials/base.py +71 -0
- agentworks/git_credentials/github.py +22 -0
- agentworks/nerf-config.yaml +16 -0
- agentworks/output.py +296 -0
- agentworks/remote_exec.py +286 -0
- agentworks/sample-config.toml +289 -0
- agentworks/sessions/__init__.py +0 -0
- agentworks/sessions/console.py +164 -0
- agentworks/sessions/manager.py +1297 -0
- agentworks/sessions/templates.py +101 -0
- agentworks/sessions/tmux.py +503 -0
- agentworks/sources.py +303 -0
- agentworks/ssh.py +759 -0
- agentworks/ssh_config.py +255 -0
- agentworks/vm_hosts/__init__.py +0 -0
- agentworks/vm_hosts/manager.py +86 -0
- agentworks/vms/__init__.py +0 -0
- agentworks/vms/backup.py +409 -0
- agentworks/vms/base.py +56 -0
- agentworks/vms/bootstrap_script.py +185 -0
- agentworks/vms/cloud_init.py +55 -0
- agentworks/vms/initializer.py +1523 -0
- agentworks/vms/manager.py +1122 -0
- agentworks/vms/provisioners/__init__.py +0 -0
- agentworks/vms/provisioners/azure.py +602 -0
- agentworks/vms/provisioners/lima.py +295 -0
- agentworks/vms/provisioners/proxmox.py +279 -0
- agentworks/vms/provisioners/proxmox_api.py +261 -0
- agentworks/vms/provisioners/wsl2.py +340 -0
- agentworks/vms/templates.py +152 -0
- agentworks/workspaces/__init__.py +0 -0
- agentworks/workspaces/backends/__init__.py +0 -0
- agentworks/workspaces/backends/local.py +119 -0
- agentworks/workspaces/backends/vm.py +175 -0
- agentworks/workspaces/manager.py +1080 -0
- agentworks/workspaces/templates.py +76 -0
- agentworks/workspaces/tmuxinator.py +80 -0
- agentworks_cli-0.2.1.dist-info/METADATA +635 -0
- agentworks_cli-0.2.1.dist-info/RECORD +59 -0
- agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
- 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)
|