agentworks-cli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. agentworks/__init__.py +1 -0
  2. agentworks/agents/__init__.py +0 -0
  3. agentworks/agents/manager.py +1095 -0
  4. agentworks/agents/templates.py +145 -0
  5. agentworks/catalog.py +264 -0
  6. agentworks/catalog.toml +131 -0
  7. agentworks/cli.py +1462 -0
  8. agentworks/completions/__init__.py +33 -0
  9. agentworks/completions/bash.py +179 -0
  10. agentworks/completions/install.py +122 -0
  11. agentworks/completions/powershell.py +270 -0
  12. agentworks/completions/spec.py +216 -0
  13. agentworks/completions/zsh.py +256 -0
  14. agentworks/config.py +894 -0
  15. agentworks/db.py +1083 -0
  16. agentworks/doctor.py +430 -0
  17. agentworks/git_credentials/__init__.py +0 -0
  18. agentworks/git_credentials/azdo.py +29 -0
  19. agentworks/git_credentials/base.py +71 -0
  20. agentworks/git_credentials/github.py +22 -0
  21. agentworks/nerf-config.yaml +16 -0
  22. agentworks/output.py +296 -0
  23. agentworks/remote_exec.py +286 -0
  24. agentworks/sample-config.toml +289 -0
  25. agentworks/sessions/__init__.py +0 -0
  26. agentworks/sessions/console.py +164 -0
  27. agentworks/sessions/manager.py +1297 -0
  28. agentworks/sessions/templates.py +101 -0
  29. agentworks/sessions/tmux.py +503 -0
  30. agentworks/sources.py +303 -0
  31. agentworks/ssh.py +759 -0
  32. agentworks/ssh_config.py +255 -0
  33. agentworks/vm_hosts/__init__.py +0 -0
  34. agentworks/vm_hosts/manager.py +86 -0
  35. agentworks/vms/__init__.py +0 -0
  36. agentworks/vms/backup.py +409 -0
  37. agentworks/vms/base.py +56 -0
  38. agentworks/vms/bootstrap_script.py +185 -0
  39. agentworks/vms/cloud_init.py +55 -0
  40. agentworks/vms/initializer.py +1523 -0
  41. agentworks/vms/manager.py +1122 -0
  42. agentworks/vms/provisioners/__init__.py +0 -0
  43. agentworks/vms/provisioners/azure.py +602 -0
  44. agentworks/vms/provisioners/lima.py +295 -0
  45. agentworks/vms/provisioners/proxmox.py +279 -0
  46. agentworks/vms/provisioners/proxmox_api.py +261 -0
  47. agentworks/vms/provisioners/wsl2.py +340 -0
  48. agentworks/vms/templates.py +152 -0
  49. agentworks/workspaces/__init__.py +0 -0
  50. agentworks/workspaces/backends/__init__.py +0 -0
  51. agentworks/workspaces/backends/local.py +119 -0
  52. agentworks/workspaces/backends/vm.py +175 -0
  53. agentworks/workspaces/manager.py +1080 -0
  54. agentworks/workspaces/templates.py +76 -0
  55. agentworks/workspaces/tmuxinator.py +80 -0
  56. agentworks_cli-0.2.1.dist-info/METADATA +635 -0
  57. agentworks_cli-0.2.1.dist-info/RECORD +59 -0
  58. agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
  59. agentworks_cli-0.2.1.dist-info/entry_points.txt +2 -0
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
+ )