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
agentworks/db.py
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
"""SQLite state database for Agentworks.
|
|
2
|
+
|
|
3
|
+
Database lives at ~/.config/agentworks/agentworks.db. Created automatically on
|
|
4
|
+
first use. Schema migrations are forward-only via a version table.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from agentworks.config import CONFIG_DIR
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
DB_PATH = CONFIG_DIR / "agentworks.db"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProvisioningStatus(Enum):
|
|
24
|
+
PENDING = "pending"
|
|
25
|
+
IN_PROGRESS = "in_progress"
|
|
26
|
+
COMPLETE = "complete"
|
|
27
|
+
FAILED = "failed"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InitStatus(Enum):
|
|
31
|
+
PENDING = "pending"
|
|
32
|
+
IN_PROGRESS = "in_progress"
|
|
33
|
+
COMPLETE = "complete"
|
|
34
|
+
PARTIAL = "partial"
|
|
35
|
+
FAILED = "failed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class VMStatus(Enum):
|
|
39
|
+
RUNNING = "running"
|
|
40
|
+
STOPPED = "stopped"
|
|
41
|
+
DEALLOCATED = "deallocated"
|
|
42
|
+
UNKNOWN = "unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SessionMode(Enum):
|
|
46
|
+
ADMIN = "admin"
|
|
47
|
+
AGENT = "agent"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionStatus(Enum):
|
|
51
|
+
"""Session liveness state, computed live from has-session + PID/boot_id checks."""
|
|
52
|
+
|
|
53
|
+
OK = "ok"
|
|
54
|
+
STOPPED = "stopped"
|
|
55
|
+
BROKEN = "broken"
|
|
56
|
+
UNKNOWN = "unknown"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Sentinel PID value: session is known to be stopped (no process to check).
|
|
60
|
+
# Distinct from NULL (never checked / pre-enhancement).
|
|
61
|
+
PID_STOPPED = -1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# -- Row types -------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class VMHostRow:
|
|
69
|
+
name: str
|
|
70
|
+
ssh_host: str
|
|
71
|
+
platform: str
|
|
72
|
+
os: str | None
|
|
73
|
+
created_at: str
|
|
74
|
+
last_seen_at: str | None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class VMRow:
|
|
79
|
+
name: str
|
|
80
|
+
platform: str
|
|
81
|
+
vm_host_name: str | None
|
|
82
|
+
template: str | None
|
|
83
|
+
extra_packages: list[str]
|
|
84
|
+
provisioning_status: str
|
|
85
|
+
init_status: str
|
|
86
|
+
tailscale_host: str | None
|
|
87
|
+
azure_resource_id: str | None
|
|
88
|
+
wsl_distro_name: str | None
|
|
89
|
+
proxmox_vmid: str | None
|
|
90
|
+
cpus: int | None
|
|
91
|
+
memory_gib: int | None
|
|
92
|
+
disk_gib: int | None
|
|
93
|
+
swap_gib: int | None
|
|
94
|
+
admin_username: str
|
|
95
|
+
created_at: str
|
|
96
|
+
last_seen_at: str | None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class VMEventRow:
|
|
101
|
+
id: int
|
|
102
|
+
vm_name: str
|
|
103
|
+
event: str
|
|
104
|
+
detail: str | None
|
|
105
|
+
created_at: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class WorkspaceRow:
|
|
110
|
+
name: str
|
|
111
|
+
type: str
|
|
112
|
+
vm_name: str | None
|
|
113
|
+
template: str | None
|
|
114
|
+
workspace_path: str
|
|
115
|
+
created_at: str
|
|
116
|
+
last_seen_at: str | None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class AgentRow:
|
|
121
|
+
name: str
|
|
122
|
+
vm_name: str
|
|
123
|
+
linux_user: str
|
|
124
|
+
template: str | None
|
|
125
|
+
grant_all: bool
|
|
126
|
+
created_at: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class AgentGrantRow:
|
|
131
|
+
agent_name: str
|
|
132
|
+
workspace_name: str
|
|
133
|
+
grant_type: str # 'explicit' or 'implicit'
|
|
134
|
+
session_name: str | None # NULL for explicit, session name for implicit
|
|
135
|
+
created_at: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class SessionRow:
|
|
140
|
+
name: str
|
|
141
|
+
workspace_name: str
|
|
142
|
+
template: str
|
|
143
|
+
mode: str
|
|
144
|
+
created_at: str
|
|
145
|
+
updated_at: str
|
|
146
|
+
agent_name: str | None = None
|
|
147
|
+
created_workspace: bool = False
|
|
148
|
+
socket_path: str | None = None
|
|
149
|
+
pid: int | None = None
|
|
150
|
+
boot_id: str | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# -- Migrations ------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
MIGRATIONS: dict[int, str] = {
|
|
156
|
+
1: """
|
|
157
|
+
CREATE TABLE vm_hosts (
|
|
158
|
+
name TEXT PRIMARY KEY,
|
|
159
|
+
ssh_host TEXT NOT NULL,
|
|
160
|
+
platform TEXT NOT NULL DEFAULT 'lima',
|
|
161
|
+
os TEXT,
|
|
162
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
163
|
+
last_seen_at TEXT
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
CREATE TABLE vms (
|
|
167
|
+
name TEXT PRIMARY KEY,
|
|
168
|
+
platform TEXT NOT NULL,
|
|
169
|
+
vm_host_name TEXT,
|
|
170
|
+
extra_packages TEXT,
|
|
171
|
+
init_status TEXT NOT NULL DEFAULT 'pending',
|
|
172
|
+
ssh_public_key TEXT,
|
|
173
|
+
tailscale_host TEXT,
|
|
174
|
+
azure_resource_id TEXT,
|
|
175
|
+
wsl_distro_name TEXT,
|
|
176
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
177
|
+
last_seen_at TEXT,
|
|
178
|
+
FOREIGN KEY (vm_host_name) REFERENCES vm_hosts(name)
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE TABLE workspaces (
|
|
182
|
+
name TEXT PRIMARY KEY,
|
|
183
|
+
type TEXT NOT NULL,
|
|
184
|
+
vm_name TEXT,
|
|
185
|
+
template TEXT,
|
|
186
|
+
workspace_path TEXT NOT NULL,
|
|
187
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
188
|
+
last_seen_at TEXT,
|
|
189
|
+
FOREIGN KEY (vm_name) REFERENCES vms(name)
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
CREATE TABLE vm_git_host_keys (
|
|
193
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
194
|
+
vm_name TEXT NOT NULL,
|
|
195
|
+
git_host_name TEXT NOT NULL,
|
|
196
|
+
remote_key_id TEXT NOT NULL,
|
|
197
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
198
|
+
FOREIGN KEY (vm_name) REFERENCES vms(name),
|
|
199
|
+
UNIQUE (vm_name, git_host_name)
|
|
200
|
+
);
|
|
201
|
+
""",
|
|
202
|
+
2: """
|
|
203
|
+
ALTER TABLE vms ADD COLUMN cpus INTEGER;
|
|
204
|
+
ALTER TABLE vms ADD COLUMN memory_gib INTEGER;
|
|
205
|
+
ALTER TABLE vms ADD COLUMN disk_gib INTEGER;
|
|
206
|
+
""",
|
|
207
|
+
3: """
|
|
208
|
+
ALTER TABLE vms ADD COLUMN vm_user TEXT NOT NULL DEFAULT 'agentworks';
|
|
209
|
+
""",
|
|
210
|
+
4: """
|
|
211
|
+
CREATE TABLE agents (
|
|
212
|
+
name TEXT NOT NULL,
|
|
213
|
+
workspace_name TEXT NOT NULL,
|
|
214
|
+
linux_user TEXT NOT NULL UNIQUE,
|
|
215
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
216
|
+
PRIMARY KEY (workspace_name, name),
|
|
217
|
+
FOREIGN KEY (workspace_name) REFERENCES workspaces(name)
|
|
218
|
+
);
|
|
219
|
+
""",
|
|
220
|
+
5: """
|
|
221
|
+
DROP TABLE IF EXISTS vm_git_host_keys;
|
|
222
|
+
""",
|
|
223
|
+
6: """
|
|
224
|
+
ALTER TABLE vms ADD COLUMN provisioning_status TEXT NOT NULL DEFAULT 'pending';
|
|
225
|
+
|
|
226
|
+
-- Migrate existing init_status values to the two-column model.
|
|
227
|
+
-- Use tailscale_host presence to distinguish provisioning vs init failures.
|
|
228
|
+
UPDATE vms SET provisioning_status = CASE
|
|
229
|
+
WHEN init_status = 'pending' THEN 'pending'
|
|
230
|
+
WHEN init_status = 'bootstrapping' THEN 'in_progress'
|
|
231
|
+
WHEN init_status IN ('tailscale_up', 'initializing', 'complete', 'partial') THEN 'complete'
|
|
232
|
+
WHEN init_status = 'failed' AND tailscale_host IS NOT NULL THEN 'complete'
|
|
233
|
+
WHEN init_status = 'failed' AND tailscale_host IS NULL THEN 'failed'
|
|
234
|
+
ELSE 'pending'
|
|
235
|
+
END;
|
|
236
|
+
|
|
237
|
+
UPDATE vms SET init_status = CASE
|
|
238
|
+
WHEN init_status = 'pending' THEN 'pending'
|
|
239
|
+
WHEN init_status = 'bootstrapping' THEN 'pending'
|
|
240
|
+
WHEN init_status = 'tailscale_up' THEN 'pending'
|
|
241
|
+
WHEN init_status = 'initializing' THEN 'in_progress'
|
|
242
|
+
WHEN init_status = 'complete' THEN 'complete'
|
|
243
|
+
WHEN init_status = 'partial' THEN 'partial'
|
|
244
|
+
WHEN init_status = 'failed' AND tailscale_host IS NOT NULL THEN 'failed'
|
|
245
|
+
WHEN init_status = 'failed' AND tailscale_host IS NULL THEN 'pending'
|
|
246
|
+
ELSE 'pending'
|
|
247
|
+
END;
|
|
248
|
+
|
|
249
|
+
CREATE TABLE vm_events (
|
|
250
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
251
|
+
vm_name TEXT NOT NULL,
|
|
252
|
+
event TEXT NOT NULL,
|
|
253
|
+
detail TEXT,
|
|
254
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
255
|
+
FOREIGN KEY (vm_name) REFERENCES vms(name)
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
CREATE INDEX idx_vm_events_vm_name ON vm_events(vm_name);
|
|
259
|
+
""",
|
|
260
|
+
7: """
|
|
261
|
+
ALTER TABLE vms RENAME COLUMN vm_user TO admin_username;
|
|
262
|
+
""",
|
|
263
|
+
8: """
|
|
264
|
+
CREATE TABLE tasks (
|
|
265
|
+
name TEXT NOT NULL,
|
|
266
|
+
workspace_name TEXT NOT NULL,
|
|
267
|
+
template TEXT NOT NULL,
|
|
268
|
+
mode TEXT NOT NULL DEFAULT 'admin',
|
|
269
|
+
linux_user TEXT NOT NULL,
|
|
270
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
271
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
272
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
273
|
+
PRIMARY KEY (workspace_name, name),
|
|
274
|
+
FOREIGN KEY (workspace_name) REFERENCES workspaces(name)
|
|
275
|
+
);
|
|
276
|
+
""",
|
|
277
|
+
9: """
|
|
278
|
+
UPDATE workspaces SET template = 'default' WHERE template = '(built-in)';
|
|
279
|
+
""",
|
|
280
|
+
10: """
|
|
281
|
+
ALTER TABLE vms ADD COLUMN swap_gib INTEGER;
|
|
282
|
+
UPDATE vms SET swap_gib = 0;
|
|
283
|
+
""",
|
|
284
|
+
11: """
|
|
285
|
+
ALTER TABLE vms ADD COLUMN template TEXT;
|
|
286
|
+
UPDATE vms SET template = 'default';
|
|
287
|
+
""",
|
|
288
|
+
12: """
|
|
289
|
+
ALTER TABLE agents ADD COLUMN template TEXT;
|
|
290
|
+
UPDATE agents SET template = 'default';
|
|
291
|
+
""",
|
|
292
|
+
13: """
|
|
293
|
+
-- Restructure agents: workspace-scoped -> VM-scoped
|
|
294
|
+
CREATE TABLE agents_new (
|
|
295
|
+
name TEXT PRIMARY KEY,
|
|
296
|
+
vm_name TEXT NOT NULL,
|
|
297
|
+
linux_user TEXT NOT NULL UNIQUE,
|
|
298
|
+
template TEXT,
|
|
299
|
+
grant_all INTEGER NOT NULL DEFAULT 0,
|
|
300
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
301
|
+
FOREIGN KEY (vm_name) REFERENCES vms(name) ON DELETE CASCADE
|
|
302
|
+
);
|
|
303
|
+
INSERT INTO agents_new (name, vm_name, linux_user, template, grant_all, created_at)
|
|
304
|
+
SELECT a.name, w.vm_name, 'agt--' || a.name, a.template, 0, a.created_at
|
|
305
|
+
FROM agents a JOIN workspaces w ON a.workspace_name = w.name
|
|
306
|
+
WHERE w.vm_name IS NOT NULL;
|
|
307
|
+
DROP TABLE agents;
|
|
308
|
+
ALTER TABLE agents_new RENAME TO agents;
|
|
309
|
+
|
|
310
|
+
-- Workspace grants table
|
|
311
|
+
CREATE TABLE IF NOT EXISTS agent_workspace_grants (
|
|
312
|
+
agent_name TEXT NOT NULL,
|
|
313
|
+
workspace_name TEXT NOT NULL,
|
|
314
|
+
grant_type TEXT NOT NULL,
|
|
315
|
+
task_name TEXT,
|
|
316
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
317
|
+
FOREIGN KEY (agent_name) REFERENCES agents(name) ON DELETE CASCADE,
|
|
318
|
+
FOREIGN KEY (workspace_name) REFERENCES workspaces(name) ON DELETE CASCADE
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
-- Rename workspace groups: ws-<name> -> ws--<name>
|
|
322
|
+
-- (actual Linux group rename must be done manually on VMs)
|
|
323
|
+
""",
|
|
324
|
+
14: """
|
|
325
|
+
ALTER TABLE tasks ADD COLUMN agent_name TEXT REFERENCES agents(name);
|
|
326
|
+
-- Backfill agent_name from linux_user for existing agent-mode tasks
|
|
327
|
+
UPDATE tasks SET agent_name = (
|
|
328
|
+
SELECT a.name FROM agents a WHERE a.linux_user = tasks.linux_user
|
|
329
|
+
) WHERE mode = 'agent';
|
|
330
|
+
ALTER TABLE tasks DROP COLUMN linux_user;
|
|
331
|
+
""",
|
|
332
|
+
15: """
|
|
333
|
+
ALTER TABLE tasks ADD COLUMN created_workspace INTEGER NOT NULL DEFAULT 0;
|
|
334
|
+
""",
|
|
335
|
+
16: """
|
|
336
|
+
ALTER TABLE vms ADD COLUMN proxmox_vmid TEXT;
|
|
337
|
+
""",
|
|
338
|
+
17: """
|
|
339
|
+
-- Rename tasks -> sessions with globally unique names
|
|
340
|
+
CREATE TABLE sessions (
|
|
341
|
+
name TEXT PRIMARY KEY,
|
|
342
|
+
workspace_name TEXT NOT NULL,
|
|
343
|
+
template TEXT NOT NULL,
|
|
344
|
+
mode TEXT NOT NULL DEFAULT 'admin',
|
|
345
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
346
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
347
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
348
|
+
agent_name TEXT REFERENCES agents(name),
|
|
349
|
+
created_workspace INTEGER NOT NULL DEFAULT 0,
|
|
350
|
+
socket_path TEXT,
|
|
351
|
+
FOREIGN KEY (workspace_name) REFERENCES workspaces(name)
|
|
352
|
+
);
|
|
353
|
+
INSERT INTO sessions
|
|
354
|
+
(name, workspace_name, template, mode, status,
|
|
355
|
+
created_at, updated_at, agent_name, created_workspace)
|
|
356
|
+
SELECT workspace_name || '--' || name, workspace_name,
|
|
357
|
+
template, mode, status, created_at, updated_at,
|
|
358
|
+
agent_name, created_workspace
|
|
359
|
+
FROM tasks;
|
|
360
|
+
DROP TABLE tasks;
|
|
361
|
+
""",
|
|
362
|
+
18: """
|
|
363
|
+
-- Rename task_name -> session_name in agent_workspace_grants
|
|
364
|
+
CREATE TABLE agent_workspace_grants_new (
|
|
365
|
+
agent_name TEXT NOT NULL,
|
|
366
|
+
workspace_name TEXT NOT NULL,
|
|
367
|
+
grant_type TEXT NOT NULL,
|
|
368
|
+
session_name TEXT,
|
|
369
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
370
|
+
FOREIGN KEY (agent_name) REFERENCES agents(name) ON DELETE CASCADE,
|
|
371
|
+
FOREIGN KEY (workspace_name) REFERENCES workspaces(name) ON DELETE CASCADE
|
|
372
|
+
);
|
|
373
|
+
INSERT INTO agent_workspace_grants_new (agent_name, workspace_name, grant_type, session_name, created_at)
|
|
374
|
+
SELECT agent_name, workspace_name, grant_type,
|
|
375
|
+
CASE WHEN task_name IS NOT NULL THEN workspace_name || '--' || task_name ELSE NULL END,
|
|
376
|
+
created_at
|
|
377
|
+
FROM agent_workspace_grants;
|
|
378
|
+
DROP TABLE agent_workspace_grants;
|
|
379
|
+
ALTER TABLE agent_workspace_grants_new RENAME TO agent_workspace_grants;
|
|
380
|
+
""",
|
|
381
|
+
# -- Enforce: agent sessions must have a socket_path ---------------------
|
|
382
|
+
# Recreate sessions table with a CHECK constraint. The INSERT will fail
|
|
383
|
+
# if any agent sessions have NULL socket_path (legacy default-server
|
|
384
|
+
# mode). If this happens, revert to the previous version and run
|
|
385
|
+
# 'session restart --force' or 'session delete' for each legacy agent
|
|
386
|
+
# session before upgrading.
|
|
387
|
+
19: """
|
|
388
|
+
CREATE TABLE sessions_new (
|
|
389
|
+
name TEXT PRIMARY KEY,
|
|
390
|
+
workspace_name TEXT NOT NULL,
|
|
391
|
+
template TEXT NOT NULL,
|
|
392
|
+
mode TEXT NOT NULL DEFAULT 'admin',
|
|
393
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
394
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
395
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
396
|
+
agent_name TEXT REFERENCES agents(name),
|
|
397
|
+
created_workspace INTEGER NOT NULL DEFAULT 0,
|
|
398
|
+
socket_path TEXT,
|
|
399
|
+
FOREIGN KEY (workspace_name) REFERENCES workspaces(name),
|
|
400
|
+
CHECK (mode != 'agent' OR socket_path IS NOT NULL)
|
|
401
|
+
);
|
|
402
|
+
INSERT INTO sessions_new SELECT * FROM sessions;
|
|
403
|
+
DROP TABLE sessions;
|
|
404
|
+
ALTER TABLE sessions_new RENAME TO sessions;
|
|
405
|
+
""",
|
|
406
|
+
# -- Drop cached status, add PID for live liveness checks -----------------
|
|
407
|
+
20: """
|
|
408
|
+
ALTER TABLE sessions DROP COLUMN status;
|
|
409
|
+
ALTER TABLE sessions ADD COLUMN pid INTEGER;
|
|
410
|
+
""",
|
|
411
|
+
# -- Add boot ID for PID staleness detection across VM reboots ----------
|
|
412
|
+
21: """
|
|
413
|
+
ALTER TABLE sessions ADD COLUMN boot_id TEXT;
|
|
414
|
+
""",
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
LATEST_VERSION = max(MIGRATIONS)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# -- Database class --------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class Database:
|
|
424
|
+
"""Typed interface to the Agentworks state database."""
|
|
425
|
+
|
|
426
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
427
|
+
db_path = path or DB_PATH
|
|
428
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
self._conn = sqlite3.connect(str(db_path))
|
|
430
|
+
self._conn.row_factory = sqlite3.Row
|
|
431
|
+
self._conn.execute("PRAGMA foreign_keys = ON")
|
|
432
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
433
|
+
self._migrate()
|
|
434
|
+
|
|
435
|
+
def close(self) -> None:
|
|
436
|
+
self._conn.close()
|
|
437
|
+
|
|
438
|
+
@staticmethod
|
|
439
|
+
def check_schema(path: Path | None = None) -> tuple[bool, int, int]:
|
|
440
|
+
"""Check DB schema version without migrating.
|
|
441
|
+
|
|
442
|
+
Returns (exists, current_version, latest_version).
|
|
443
|
+
"""
|
|
444
|
+
db_path = path or DB_PATH
|
|
445
|
+
if not db_path.exists():
|
|
446
|
+
return (False, 0, LATEST_VERSION)
|
|
447
|
+
conn = sqlite3.connect(str(db_path))
|
|
448
|
+
try:
|
|
449
|
+
row = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()
|
|
450
|
+
current = row[0] or 0
|
|
451
|
+
except sqlite3.OperationalError:
|
|
452
|
+
current = 0
|
|
453
|
+
finally:
|
|
454
|
+
conn.close()
|
|
455
|
+
return (True, current, LATEST_VERSION)
|
|
456
|
+
|
|
457
|
+
def _migrate(self) -> None:
|
|
458
|
+
self._conn.execute(
|
|
459
|
+
"CREATE TABLE IF NOT EXISTS schema_version ("
|
|
460
|
+
" version INTEGER NOT NULL,"
|
|
461
|
+
" applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))"
|
|
462
|
+
")"
|
|
463
|
+
)
|
|
464
|
+
row = self._conn.execute("SELECT MAX(version) FROM schema_version").fetchone()
|
|
465
|
+
current = row[0] or 0
|
|
466
|
+
|
|
467
|
+
for version in range(current + 1, LATEST_VERSION + 1):
|
|
468
|
+
for stmt in MIGRATIONS[version].split(";"):
|
|
469
|
+
stmt = stmt.strip()
|
|
470
|
+
if stmt:
|
|
471
|
+
self._conn.execute(stmt)
|
|
472
|
+
self._conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
|
|
473
|
+
self._conn.commit()
|
|
474
|
+
|
|
475
|
+
# -- VM Hosts ----------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def insert_vm_host(self, name: str, ssh_host: str, platform: str = "lima", os: str | None = None) -> VMHostRow:
|
|
478
|
+
self._conn.execute(
|
|
479
|
+
"INSERT INTO vm_hosts (name, ssh_host, platform, os) VALUES (?, ?, ?, ?)",
|
|
480
|
+
(name, ssh_host, platform, os),
|
|
481
|
+
)
|
|
482
|
+
self._conn.commit()
|
|
483
|
+
result = self.get_vm_host(name)
|
|
484
|
+
assert result is not None
|
|
485
|
+
return result
|
|
486
|
+
|
|
487
|
+
def get_vm_host(self, name: str) -> VMHostRow | None:
|
|
488
|
+
row = self._conn.execute("SELECT * FROM vm_hosts WHERE name = ?", (name,)).fetchone()
|
|
489
|
+
return _to_vm_host(row) if row else None
|
|
490
|
+
|
|
491
|
+
def list_vm_hosts(self) -> list[VMHostRow]:
|
|
492
|
+
rows = self._conn.execute("SELECT * FROM vm_hosts ORDER BY name").fetchall()
|
|
493
|
+
return [_to_vm_host(r) for r in rows]
|
|
494
|
+
|
|
495
|
+
def update_vm_host_os(self, name: str, os: str) -> None:
|
|
496
|
+
self._conn.execute("UPDATE vm_hosts SET os = ? WHERE name = ?", (os, name))
|
|
497
|
+
self._conn.commit()
|
|
498
|
+
|
|
499
|
+
def update_vm_host_last_seen(self, name: str) -> None:
|
|
500
|
+
self._conn.execute(
|
|
501
|
+
"UPDATE vm_hosts SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE name = ?",
|
|
502
|
+
(name,),
|
|
503
|
+
)
|
|
504
|
+
self._conn.commit()
|
|
505
|
+
|
|
506
|
+
def delete_vm_host(self, name: str) -> None:
|
|
507
|
+
self._conn.execute("DELETE FROM vm_hosts WHERE name = ?", (name,))
|
|
508
|
+
self._conn.commit()
|
|
509
|
+
|
|
510
|
+
def count_vms_on_host(self, vm_host_name: str) -> int:
|
|
511
|
+
row = self._conn.execute("SELECT COUNT(*) FROM vms WHERE vm_host_name = ?", (vm_host_name,)).fetchone()
|
|
512
|
+
return int(row[0])
|
|
513
|
+
|
|
514
|
+
# -- VMs ---------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
def insert_vm(
|
|
517
|
+
self,
|
|
518
|
+
name: str,
|
|
519
|
+
platform: str,
|
|
520
|
+
vm_host_name: str | None = None,
|
|
521
|
+
template: str | None = None,
|
|
522
|
+
azure_resource_id: str | None = None,
|
|
523
|
+
wsl_distro_name: str | None = None,
|
|
524
|
+
proxmox_vmid: str | None = None,
|
|
525
|
+
cpus: int | None = None,
|
|
526
|
+
memory_gib: int | None = None,
|
|
527
|
+
disk_gib: int | None = None,
|
|
528
|
+
swap_gib: int | None = None,
|
|
529
|
+
admin_username: str = "agentworks",
|
|
530
|
+
) -> VMRow:
|
|
531
|
+
self._conn.execute(
|
|
532
|
+
"INSERT INTO vms "
|
|
533
|
+
"(name, platform, vm_host_name, template, azure_resource_id, wsl_distro_name, "
|
|
534
|
+
"proxmox_vmid, cpus, memory_gib, disk_gib, swap_gib, admin_username) "
|
|
535
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
536
|
+
(
|
|
537
|
+
name,
|
|
538
|
+
platform,
|
|
539
|
+
vm_host_name,
|
|
540
|
+
template,
|
|
541
|
+
azure_resource_id,
|
|
542
|
+
wsl_distro_name,
|
|
543
|
+
proxmox_vmid,
|
|
544
|
+
cpus,
|
|
545
|
+
memory_gib,
|
|
546
|
+
disk_gib,
|
|
547
|
+
swap_gib,
|
|
548
|
+
admin_username,
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
self._conn.commit()
|
|
552
|
+
result = self.get_vm(name)
|
|
553
|
+
assert result is not None
|
|
554
|
+
return result
|
|
555
|
+
|
|
556
|
+
def get_vm(self, name: str) -> VMRow | None:
|
|
557
|
+
row = self._conn.execute("SELECT * FROM vms WHERE name = ?", (name,)).fetchone()
|
|
558
|
+
return _to_vm(row) if row else None
|
|
559
|
+
|
|
560
|
+
def list_vms(self) -> list[VMRow]:
|
|
561
|
+
rows = self._conn.execute("SELECT * FROM vms ORDER BY name").fetchall()
|
|
562
|
+
return [_to_vm(r) for r in rows]
|
|
563
|
+
|
|
564
|
+
def update_vm_host_ref(self, name: str, vm_host_name: str | None) -> None:
|
|
565
|
+
self._conn.execute("UPDATE vms SET vm_host_name = ? WHERE name = ?", (vm_host_name, name))
|
|
566
|
+
self._conn.commit()
|
|
567
|
+
|
|
568
|
+
def update_vm_provisioning_status(self, name: str, status: ProvisioningStatus) -> None:
|
|
569
|
+
self._conn.execute("UPDATE vms SET provisioning_status = ? WHERE name = ?", (status.value, name))
|
|
570
|
+
self._conn.commit()
|
|
571
|
+
|
|
572
|
+
def update_vm_init_status(self, name: str, status: InitStatus) -> None:
|
|
573
|
+
self._conn.execute("UPDATE vms SET init_status = ? WHERE name = ?", (status.value, name))
|
|
574
|
+
self._conn.commit()
|
|
575
|
+
|
|
576
|
+
def update_vm_tailscale(self, name: str, tailscale_host: str) -> None:
|
|
577
|
+
self._conn.execute("UPDATE vms SET tailscale_host = ? WHERE name = ?", (tailscale_host, name))
|
|
578
|
+
self._conn.commit()
|
|
579
|
+
|
|
580
|
+
def clear_vm_tailscale(self, name: str) -> None:
|
|
581
|
+
self._conn.execute("UPDATE vms SET tailscale_host = NULL WHERE name = ?", (name,))
|
|
582
|
+
self._conn.commit()
|
|
583
|
+
|
|
584
|
+
def update_vm_azure_resource_id(self, name: str, azure_resource_id: str) -> None:
|
|
585
|
+
self._conn.execute("UPDATE vms SET azure_resource_id = ? WHERE name = ?", (azure_resource_id, name))
|
|
586
|
+
self._conn.commit()
|
|
587
|
+
|
|
588
|
+
def update_vm_wsl_distro_name(self, name: str, wsl_distro_name: str) -> None:
|
|
589
|
+
self._conn.execute("UPDATE vms SET wsl_distro_name = ? WHERE name = ?", (wsl_distro_name, name))
|
|
590
|
+
self._conn.commit()
|
|
591
|
+
|
|
592
|
+
def update_vm_proxmox_vmid(self, name: str, proxmox_vmid: str) -> None:
|
|
593
|
+
self._conn.execute("UPDATE vms SET proxmox_vmid = ? WHERE name = ?", (proxmox_vmid, name))
|
|
594
|
+
self._conn.commit()
|
|
595
|
+
|
|
596
|
+
def update_vm_last_seen(self, name: str) -> None:
|
|
597
|
+
self._conn.execute(
|
|
598
|
+
"UPDATE vms SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE name = ?",
|
|
599
|
+
(name,),
|
|
600
|
+
)
|
|
601
|
+
self._conn.commit()
|
|
602
|
+
|
|
603
|
+
def delete_vm(self, name: str) -> None:
|
|
604
|
+
self._conn.execute(
|
|
605
|
+
"DELETE FROM sessions WHERE workspace_name IN (SELECT name FROM workspaces WHERE vm_name = ?)",
|
|
606
|
+
(name,),
|
|
607
|
+
)
|
|
608
|
+
# Agents are VM-scoped; grants cascade via FK
|
|
609
|
+
self._conn.execute("DELETE FROM agents WHERE vm_name = ?", (name,))
|
|
610
|
+
self._conn.execute(
|
|
611
|
+
"DELETE FROM agent_workspace_grants WHERE workspace_name IN "
|
|
612
|
+
"(SELECT name FROM workspaces WHERE vm_name = ?)",
|
|
613
|
+
(name,),
|
|
614
|
+
)
|
|
615
|
+
self._conn.execute("DELETE FROM workspaces WHERE vm_name = ?", (name,))
|
|
616
|
+
self._conn.execute("DELETE FROM vm_events WHERE vm_name = ?", (name,))
|
|
617
|
+
self._conn.execute("DELETE FROM vms WHERE name = ?", (name,))
|
|
618
|
+
self._conn.commit()
|
|
619
|
+
|
|
620
|
+
# -- Workspaces --------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
def insert_workspace(
|
|
623
|
+
self,
|
|
624
|
+
name: str,
|
|
625
|
+
ws_type: str,
|
|
626
|
+
workspace_path: str,
|
|
627
|
+
vm_name: str | None = None,
|
|
628
|
+
template: str | None = None,
|
|
629
|
+
) -> WorkspaceRow:
|
|
630
|
+
self._conn.execute(
|
|
631
|
+
"INSERT INTO workspaces (name, type, vm_name, template, workspace_path) VALUES (?, ?, ?, ?, ?)",
|
|
632
|
+
(name, ws_type, vm_name, template, workspace_path),
|
|
633
|
+
)
|
|
634
|
+
self._conn.commit()
|
|
635
|
+
result = self.get_workspace(name)
|
|
636
|
+
assert result is not None
|
|
637
|
+
return result
|
|
638
|
+
|
|
639
|
+
def get_workspace(self, name: str) -> WorkspaceRow | None:
|
|
640
|
+
row = self._conn.execute("SELECT * FROM workspaces WHERE name = ?", (name,)).fetchone()
|
|
641
|
+
return _to_workspace(row) if row else None
|
|
642
|
+
|
|
643
|
+
def list_workspaces(self, vm_name: str | None = None, ws_type: str | None = None) -> list[WorkspaceRow]:
|
|
644
|
+
query = "SELECT * FROM workspaces"
|
|
645
|
+
params: list[str] = []
|
|
646
|
+
conditions: list[str] = []
|
|
647
|
+
|
|
648
|
+
if vm_name is not None:
|
|
649
|
+
conditions.append("vm_name = ?")
|
|
650
|
+
params.append(vm_name)
|
|
651
|
+
if ws_type is not None:
|
|
652
|
+
conditions.append("type = ?")
|
|
653
|
+
params.append(ws_type)
|
|
654
|
+
|
|
655
|
+
if conditions:
|
|
656
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
657
|
+
query += " ORDER BY name"
|
|
658
|
+
|
|
659
|
+
rows = self._conn.execute(query, params).fetchall()
|
|
660
|
+
return [_to_workspace(r) for r in rows]
|
|
661
|
+
|
|
662
|
+
def update_workspace_path(self, name: str, workspace_path: str) -> None:
|
|
663
|
+
self._conn.execute(
|
|
664
|
+
"UPDATE workspaces SET workspace_path = ? WHERE name = ?",
|
|
665
|
+
(workspace_path, name),
|
|
666
|
+
)
|
|
667
|
+
self._conn.commit()
|
|
668
|
+
|
|
669
|
+
def update_workspace_last_seen(self, name: str) -> None:
|
|
670
|
+
self._conn.execute(
|
|
671
|
+
"UPDATE workspaces SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE name = ?",
|
|
672
|
+
(name,),
|
|
673
|
+
)
|
|
674
|
+
self._conn.commit()
|
|
675
|
+
|
|
676
|
+
def delete_workspace(self, name: str) -> None:
|
|
677
|
+
self._conn.execute("DELETE FROM sessions WHERE workspace_name = ?", (name,))
|
|
678
|
+
# Grants cascade via FK; agents are VM-scoped so not deleted with workspaces
|
|
679
|
+
self._conn.execute("DELETE FROM agent_workspace_grants WHERE workspace_name = ?", (name,))
|
|
680
|
+
self._conn.execute("DELETE FROM workspaces WHERE name = ?", (name,))
|
|
681
|
+
self._conn.commit()
|
|
682
|
+
|
|
683
|
+
def count_workspaces_on_vm(self, vm_name: str) -> int:
|
|
684
|
+
row = self._conn.execute("SELECT COUNT(*) FROM workspaces WHERE vm_name = ?", (vm_name,)).fetchone()
|
|
685
|
+
return int(row[0])
|
|
686
|
+
|
|
687
|
+
def count_agents_on_vm(self, vm_name: str) -> int:
|
|
688
|
+
row = self._conn.execute(
|
|
689
|
+
"SELECT COUNT(*) FROM agents WHERE vm_name = ?",
|
|
690
|
+
(vm_name,),
|
|
691
|
+
).fetchone()
|
|
692
|
+
return int(row[0])
|
|
693
|
+
|
|
694
|
+
def count_sessions_on_vm(self, vm_name: str) -> int:
|
|
695
|
+
row = self._conn.execute(
|
|
696
|
+
"SELECT COUNT(*) FROM sessions WHERE workspace_name IN (SELECT name FROM workspaces WHERE vm_name = ?)",
|
|
697
|
+
(vm_name,),
|
|
698
|
+
).fetchone()
|
|
699
|
+
return int(row[0])
|
|
700
|
+
|
|
701
|
+
# -- Agents ------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
def insert_agent(
|
|
704
|
+
self,
|
|
705
|
+
name: str,
|
|
706
|
+
vm_name: str,
|
|
707
|
+
linux_user: str,
|
|
708
|
+
template: str | None = None,
|
|
709
|
+
grant_all: bool = False,
|
|
710
|
+
) -> AgentRow:
|
|
711
|
+
self._conn.execute(
|
|
712
|
+
"INSERT INTO agents (name, vm_name, linux_user, template, grant_all) VALUES (?, ?, ?, ?, ?)",
|
|
713
|
+
(name, vm_name, linux_user, template, int(grant_all)),
|
|
714
|
+
)
|
|
715
|
+
self._conn.commit()
|
|
716
|
+
result = self.get_agent(name)
|
|
717
|
+
assert result is not None
|
|
718
|
+
return result
|
|
719
|
+
|
|
720
|
+
def get_agent(self, name: str) -> AgentRow | None:
|
|
721
|
+
row = self._conn.execute(
|
|
722
|
+
"SELECT * FROM agents WHERE name = ?",
|
|
723
|
+
(name,),
|
|
724
|
+
).fetchone()
|
|
725
|
+
return _to_agent(row) if row else None
|
|
726
|
+
|
|
727
|
+
def list_agents(self, vm_name: str | None = None) -> list[AgentRow]:
|
|
728
|
+
if vm_name is not None:
|
|
729
|
+
rows = self._conn.execute(
|
|
730
|
+
"SELECT * FROM agents WHERE vm_name = ? ORDER BY name",
|
|
731
|
+
(vm_name,),
|
|
732
|
+
).fetchall()
|
|
733
|
+
else:
|
|
734
|
+
rows = self._conn.execute(
|
|
735
|
+
"SELECT * FROM agents ORDER BY vm_name, name",
|
|
736
|
+
).fetchall()
|
|
737
|
+
return [_to_agent(r) for r in rows]
|
|
738
|
+
|
|
739
|
+
def delete_agent(self, name: str) -> None:
|
|
740
|
+
# Grants cascade via FK
|
|
741
|
+
self._conn.execute("DELETE FROM agents WHERE name = ?", (name,))
|
|
742
|
+
self._conn.commit()
|
|
743
|
+
|
|
744
|
+
def list_agents_on_vm_with_grant_all(self, vm_name: str) -> list[AgentRow]:
|
|
745
|
+
"""List agents on a VM that have grant_all enabled."""
|
|
746
|
+
rows = self._conn.execute(
|
|
747
|
+
"SELECT * FROM agents WHERE vm_name = ? AND grant_all = 1 ORDER BY name",
|
|
748
|
+
(vm_name,),
|
|
749
|
+
).fetchall()
|
|
750
|
+
return [_to_agent(r) for r in rows]
|
|
751
|
+
|
|
752
|
+
# -- Agent workspace grants ------------------------------------------------
|
|
753
|
+
|
|
754
|
+
def insert_agent_grant(
|
|
755
|
+
self,
|
|
756
|
+
agent_name: str,
|
|
757
|
+
workspace_name: str,
|
|
758
|
+
grant_type: str,
|
|
759
|
+
session_name: str | None = None,
|
|
760
|
+
) -> None:
|
|
761
|
+
self._conn.execute(
|
|
762
|
+
"INSERT OR IGNORE INTO agent_workspace_grants "
|
|
763
|
+
"(agent_name, workspace_name, grant_type, session_name) "
|
|
764
|
+
"VALUES (?, ?, ?, ?)",
|
|
765
|
+
(agent_name, workspace_name, grant_type, session_name),
|
|
766
|
+
)
|
|
767
|
+
self._conn.commit()
|
|
768
|
+
|
|
769
|
+
def delete_agent_grant(
|
|
770
|
+
self,
|
|
771
|
+
agent_name: str,
|
|
772
|
+
workspace_name: str,
|
|
773
|
+
grant_type: str,
|
|
774
|
+
session_name: str | None = None,
|
|
775
|
+
) -> None:
|
|
776
|
+
if session_name is not None:
|
|
777
|
+
self._conn.execute(
|
|
778
|
+
"DELETE FROM agent_workspace_grants "
|
|
779
|
+
"WHERE agent_name = ? AND workspace_name = ? AND grant_type = ? AND session_name = ?",
|
|
780
|
+
(agent_name, workspace_name, grant_type, session_name),
|
|
781
|
+
)
|
|
782
|
+
else:
|
|
783
|
+
self._conn.execute(
|
|
784
|
+
"DELETE FROM agent_workspace_grants "
|
|
785
|
+
"WHERE agent_name = ? AND workspace_name = ? AND grant_type = ? AND session_name IS NULL",
|
|
786
|
+
(agent_name, workspace_name, grant_type),
|
|
787
|
+
)
|
|
788
|
+
self._conn.commit()
|
|
789
|
+
|
|
790
|
+
def delete_explicit_grants(self, agent_name: str) -> None:
|
|
791
|
+
"""Remove all explicit grants for an agent."""
|
|
792
|
+
self._conn.execute(
|
|
793
|
+
"DELETE FROM agent_workspace_grants WHERE agent_name = ? AND grant_type = 'explicit'",
|
|
794
|
+
(agent_name,),
|
|
795
|
+
)
|
|
796
|
+
self._conn.commit()
|
|
797
|
+
|
|
798
|
+
def has_any_grant(self, agent_name: str, workspace_name: str) -> bool:
|
|
799
|
+
"""Check if an agent has any grant (explicit or implicit) for a workspace."""
|
|
800
|
+
row = self._conn.execute(
|
|
801
|
+
"SELECT COUNT(*) FROM agent_workspace_grants WHERE agent_name = ? AND workspace_name = ?",
|
|
802
|
+
(agent_name, workspace_name),
|
|
803
|
+
).fetchone()
|
|
804
|
+
return int(row[0]) > 0
|
|
805
|
+
|
|
806
|
+
def list_agent_grants(self, agent_name: str) -> list[AgentGrantRow]:
|
|
807
|
+
rows = self._conn.execute(
|
|
808
|
+
"SELECT * FROM agent_workspace_grants WHERE agent_name = ? ORDER BY workspace_name",
|
|
809
|
+
(agent_name,),
|
|
810
|
+
).fetchall()
|
|
811
|
+
return [_to_agent_grant(r) for r in rows]
|
|
812
|
+
|
|
813
|
+
def list_granted_workspaces(self, agent_name: str) -> list[str]:
|
|
814
|
+
"""List distinct workspace names the agent has access to."""
|
|
815
|
+
rows = self._conn.execute(
|
|
816
|
+
"SELECT DISTINCT workspace_name FROM agent_workspace_grants WHERE agent_name = ? ORDER BY workspace_name",
|
|
817
|
+
(agent_name,),
|
|
818
|
+
).fetchall()
|
|
819
|
+
return [row[0] for row in rows]
|
|
820
|
+
|
|
821
|
+
def list_granted_workspaces_with_types(self, agent_name: str) -> list[tuple[str, bool, bool]]:
|
|
822
|
+
"""List workspaces with grant type info: (name, has_explicit, has_implicit)."""
|
|
823
|
+
rows = self._conn.execute(
|
|
824
|
+
"SELECT workspace_name, grant_type FROM agent_workspace_grants "
|
|
825
|
+
"WHERE agent_name = ? ORDER BY workspace_name",
|
|
826
|
+
(agent_name,),
|
|
827
|
+
).fetchall()
|
|
828
|
+
# Aggregate by workspace
|
|
829
|
+
ws_map: dict[str, tuple[bool, bool]] = {}
|
|
830
|
+
for row in rows:
|
|
831
|
+
ws = row[0]
|
|
832
|
+
gt = row[1]
|
|
833
|
+
has_explicit, has_implicit = ws_map.get(ws, (False, False))
|
|
834
|
+
if gt == "explicit":
|
|
835
|
+
has_explicit = True
|
|
836
|
+
else:
|
|
837
|
+
has_implicit = True
|
|
838
|
+
ws_map[ws] = (has_explicit, has_implicit)
|
|
839
|
+
return [(ws, e, i) for ws, (e, i) in sorted(ws_map.items())]
|
|
840
|
+
|
|
841
|
+
def count_agent_grants(self, agent_name: str) -> int:
|
|
842
|
+
row = self._conn.execute(
|
|
843
|
+
"SELECT COUNT(DISTINCT workspace_name) FROM agent_workspace_grants WHERE agent_name = ?",
|
|
844
|
+
(agent_name,),
|
|
845
|
+
).fetchone()
|
|
846
|
+
return int(row[0])
|
|
847
|
+
|
|
848
|
+
def update_agent_grant_all(self, name: str, grant_all: bool) -> None:
|
|
849
|
+
self._conn.execute(
|
|
850
|
+
"UPDATE agents SET grant_all = ? WHERE name = ?",
|
|
851
|
+
(int(grant_all), name),
|
|
852
|
+
)
|
|
853
|
+
self._conn.commit()
|
|
854
|
+
|
|
855
|
+
# -- Sessions ----------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
def insert_session(
|
|
858
|
+
self,
|
|
859
|
+
name: str,
|
|
860
|
+
workspace_name: str,
|
|
861
|
+
template: str,
|
|
862
|
+
mode: SessionMode,
|
|
863
|
+
agent_name: str | None = None,
|
|
864
|
+
created_workspace: bool = False,
|
|
865
|
+
socket_path: str | None = None,
|
|
866
|
+
) -> SessionRow:
|
|
867
|
+
self._conn.execute(
|
|
868
|
+
"INSERT INTO sessions (name, workspace_name, template, mode, agent_name, created_workspace, socket_path)"
|
|
869
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
870
|
+
(name, workspace_name, template, mode.value, agent_name, int(created_workspace), socket_path),
|
|
871
|
+
)
|
|
872
|
+
self._conn.commit()
|
|
873
|
+
result = self.get_session(name)
|
|
874
|
+
assert result is not None
|
|
875
|
+
return result
|
|
876
|
+
|
|
877
|
+
def get_session(self, name: str) -> SessionRow | None:
|
|
878
|
+
row = self._conn.execute(
|
|
879
|
+
"SELECT * FROM sessions WHERE name = ?",
|
|
880
|
+
(name,),
|
|
881
|
+
).fetchone()
|
|
882
|
+
return _to_session(row) if row else None
|
|
883
|
+
|
|
884
|
+
def list_sessions(self, workspace_name: str | None = None) -> list[SessionRow]:
|
|
885
|
+
if workspace_name is not None:
|
|
886
|
+
rows = self._conn.execute(
|
|
887
|
+
"SELECT * FROM sessions WHERE workspace_name = ? ORDER BY name",
|
|
888
|
+
(workspace_name,),
|
|
889
|
+
).fetchall()
|
|
890
|
+
else:
|
|
891
|
+
rows = self._conn.execute(
|
|
892
|
+
"SELECT * FROM sessions ORDER BY workspace_name, name",
|
|
893
|
+
).fetchall()
|
|
894
|
+
return [_to_session(r) for r in rows]
|
|
895
|
+
|
|
896
|
+
def update_session_pid(self, name: str, pid: int | None, boot_id: str | None = None) -> None:
|
|
897
|
+
"""Store or clear the PID and boot ID for a session.
|
|
898
|
+
|
|
899
|
+
Valid pid values: None, PID_STOPPED (-1), or a positive integer.
|
|
900
|
+
When setting a positive PID, boot_id is required. When clearing
|
|
901
|
+
(PID_STOPPED or None), COALESCE preserves the existing boot_id.
|
|
902
|
+
"""
|
|
903
|
+
if pid is not None and pid != PID_STOPPED and pid <= 0:
|
|
904
|
+
raise ValueError(f"invalid PID: {pid} (must be None, PID_STOPPED, or > 0)")
|
|
905
|
+
if pid is not None and pid > 0 and boot_id is None:
|
|
906
|
+
raise ValueError(f"boot_id is required when setting a positive PID ({pid})")
|
|
907
|
+
self._conn.execute(
|
|
908
|
+
"UPDATE sessions SET pid = ?, boot_id = COALESCE(?, boot_id), "
|
|
909
|
+
"updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') "
|
|
910
|
+
"WHERE name = ?",
|
|
911
|
+
(pid, boot_id, name),
|
|
912
|
+
)
|
|
913
|
+
self._conn.commit()
|
|
914
|
+
|
|
915
|
+
def update_session_socket_path(self, name: str, socket_path: str | None) -> None:
|
|
916
|
+
self._conn.execute(
|
|
917
|
+
"UPDATE sessions SET socket_path = ?, "
|
|
918
|
+
"updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') "
|
|
919
|
+
"WHERE name = ?",
|
|
920
|
+
(socket_path, name),
|
|
921
|
+
)
|
|
922
|
+
self._conn.commit()
|
|
923
|
+
|
|
924
|
+
def delete_session(self, name: str) -> None:
|
|
925
|
+
self._conn.execute(
|
|
926
|
+
"DELETE FROM sessions WHERE name = ?",
|
|
927
|
+
(name,),
|
|
928
|
+
)
|
|
929
|
+
self._conn.commit()
|
|
930
|
+
|
|
931
|
+
def delete_sessions_for_workspace(self, workspace_name: str) -> list[SessionRow]:
|
|
932
|
+
"""Delete all sessions for a workspace, returning the deleted sessions."""
|
|
933
|
+
sessions = self.list_sessions(workspace_name=workspace_name)
|
|
934
|
+
self._conn.execute(
|
|
935
|
+
"DELETE FROM sessions WHERE workspace_name = ?",
|
|
936
|
+
(workspace_name,),
|
|
937
|
+
)
|
|
938
|
+
self._conn.commit()
|
|
939
|
+
return sessions
|
|
940
|
+
|
|
941
|
+
# -- VM Events ---------------------------------------------------------
|
|
942
|
+
|
|
943
|
+
def insert_vm_event(self, vm_name: str, event: str, detail: str | None = None) -> None:
|
|
944
|
+
self._conn.execute(
|
|
945
|
+
"INSERT INTO vm_events (vm_name, event, detail) VALUES (?, ?, ?)",
|
|
946
|
+
(vm_name, event, detail),
|
|
947
|
+
)
|
|
948
|
+
self._conn.commit()
|
|
949
|
+
|
|
950
|
+
def list_vm_events(self, vm_name: str) -> list[VMEventRow]:
|
|
951
|
+
rows = self._conn.execute(
|
|
952
|
+
"SELECT * FROM vm_events WHERE vm_name = ? ORDER BY id",
|
|
953
|
+
(vm_name,),
|
|
954
|
+
).fetchall()
|
|
955
|
+
return [_to_vm_event(r) for r in rows]
|
|
956
|
+
|
|
957
|
+
def snapshot_vm_backup_data(
|
|
958
|
+
self,
|
|
959
|
+
vm_name: str,
|
|
960
|
+
) -> tuple[
|
|
961
|
+
VMRow | None,
|
|
962
|
+
list[AgentRow],
|
|
963
|
+
list[WorkspaceRow],
|
|
964
|
+
list[SessionRow],
|
|
965
|
+
list[VMEventRow],
|
|
966
|
+
dict[str, list[AgentGrantRow]],
|
|
967
|
+
]:
|
|
968
|
+
"""Read all VM-related data in a single transaction for backup consistency.
|
|
969
|
+
|
|
970
|
+
Returns (vm, agents, workspaces, sessions, events, grants_by_agent).
|
|
971
|
+
"""
|
|
972
|
+
self._conn.execute("BEGIN")
|
|
973
|
+
try:
|
|
974
|
+
vm = self.get_vm(vm_name)
|
|
975
|
+
agents = self.list_agents(vm_name=vm_name)
|
|
976
|
+
workspaces = self.list_workspaces(vm_name=vm_name)
|
|
977
|
+
ws_names = {ws.name for ws in workspaces}
|
|
978
|
+
all_sessions = self.list_sessions()
|
|
979
|
+
sessions = [s for s in all_sessions if s.workspace_name in ws_names]
|
|
980
|
+
events = self.list_vm_events(vm_name)
|
|
981
|
+
grants_by_agent: dict[str, list[AgentGrantRow]] = {}
|
|
982
|
+
for agent in agents:
|
|
983
|
+
grants_by_agent[agent.name] = self.list_agent_grants(agent.name)
|
|
984
|
+
finally:
|
|
985
|
+
self._conn.execute("COMMIT")
|
|
986
|
+
return vm, agents, workspaces, sessions, events, grants_by_agent
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# -- Row converters --------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _to_vm_host(row: sqlite3.Row) -> VMHostRow:
|
|
993
|
+
return VMHostRow(
|
|
994
|
+
name=row["name"],
|
|
995
|
+
ssh_host=row["ssh_host"],
|
|
996
|
+
platform=row["platform"],
|
|
997
|
+
os=row["os"],
|
|
998
|
+
created_at=row["created_at"],
|
|
999
|
+
last_seen_at=row["last_seen_at"],
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _to_vm(row: sqlite3.Row) -> VMRow:
|
|
1004
|
+
extra = row["extra_packages"]
|
|
1005
|
+
return VMRow(
|
|
1006
|
+
name=row["name"],
|
|
1007
|
+
platform=row["platform"],
|
|
1008
|
+
vm_host_name=row["vm_host_name"],
|
|
1009
|
+
template=row["template"],
|
|
1010
|
+
extra_packages=json.loads(extra) if extra else [],
|
|
1011
|
+
provisioning_status=row["provisioning_status"],
|
|
1012
|
+
init_status=row["init_status"],
|
|
1013
|
+
tailscale_host=row["tailscale_host"],
|
|
1014
|
+
azure_resource_id=row["azure_resource_id"],
|
|
1015
|
+
wsl_distro_name=row["wsl_distro_name"],
|
|
1016
|
+
proxmox_vmid=row["proxmox_vmid"],
|
|
1017
|
+
cpus=row["cpus"],
|
|
1018
|
+
memory_gib=row["memory_gib"],
|
|
1019
|
+
disk_gib=row["disk_gib"],
|
|
1020
|
+
swap_gib=row["swap_gib"],
|
|
1021
|
+
admin_username=row["admin_username"],
|
|
1022
|
+
created_at=row["created_at"],
|
|
1023
|
+
last_seen_at=row["last_seen_at"],
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _to_workspace(row: sqlite3.Row) -> WorkspaceRow:
|
|
1028
|
+
return WorkspaceRow(
|
|
1029
|
+
name=row["name"],
|
|
1030
|
+
type=row["type"],
|
|
1031
|
+
vm_name=row["vm_name"],
|
|
1032
|
+
template=row["template"],
|
|
1033
|
+
workspace_path=row["workspace_path"],
|
|
1034
|
+
created_at=row["created_at"],
|
|
1035
|
+
last_seen_at=row["last_seen_at"],
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _to_agent(row: sqlite3.Row) -> AgentRow:
|
|
1040
|
+
return AgentRow(
|
|
1041
|
+
name=row["name"],
|
|
1042
|
+
vm_name=row["vm_name"],
|
|
1043
|
+
linux_user=row["linux_user"],
|
|
1044
|
+
template=row["template"],
|
|
1045
|
+
grant_all=bool(row["grant_all"]),
|
|
1046
|
+
created_at=row["created_at"],
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _to_agent_grant(row: sqlite3.Row) -> AgentGrantRow:
|
|
1051
|
+
return AgentGrantRow(
|
|
1052
|
+
agent_name=row["agent_name"],
|
|
1053
|
+
workspace_name=row["workspace_name"],
|
|
1054
|
+
grant_type=row["grant_type"],
|
|
1055
|
+
session_name=row["session_name"],
|
|
1056
|
+
created_at=row["created_at"],
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _to_session(row: sqlite3.Row) -> SessionRow:
|
|
1061
|
+
return SessionRow(
|
|
1062
|
+
name=row["name"],
|
|
1063
|
+
workspace_name=row["workspace_name"],
|
|
1064
|
+
template=row["template"],
|
|
1065
|
+
mode=row["mode"],
|
|
1066
|
+
created_at=row["created_at"],
|
|
1067
|
+
updated_at=row["updated_at"],
|
|
1068
|
+
agent_name=row["agent_name"],
|
|
1069
|
+
created_workspace=bool(row["created_workspace"]),
|
|
1070
|
+
socket_path=row["socket_path"],
|
|
1071
|
+
pid=row["pid"],
|
|
1072
|
+
boot_id=row["boot_id"],
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _to_vm_event(row: sqlite3.Row) -> VMEventRow:
|
|
1077
|
+
return VMEventRow(
|
|
1078
|
+
id=row["id"],
|
|
1079
|
+
vm_name=row["vm_name"],
|
|
1080
|
+
event=row["event"],
|
|
1081
|
+
detail=row["detail"],
|
|
1082
|
+
created_at=row["created_at"],
|
|
1083
|
+
)
|