cmdscope 0.1.0__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.
@@ -0,0 +1,469 @@
1
+ """SQLite persistence for sessions, events, and collector status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ from collections.abc import Iterable, Sequence
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ from cmdscope.core.events import (
14
+ CollectorState,
15
+ CollectorStatus,
16
+ Event,
17
+ EventKind,
18
+ JSONValue,
19
+ SessionStatus,
20
+ StoredEvent,
21
+ )
22
+ from cmdscope.core.session import Session, parse_session_ref
23
+ from cmdscope.errors import SessionNotFoundError, StorageError
24
+
25
+ SCHEMA_VERSION = 1
26
+
27
+ _SCHEMA = """
28
+ CREATE TABLE IF NOT EXISTS meta (
29
+ key TEXT PRIMARY KEY,
30
+ value TEXT NOT NULL
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS sessions (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ status TEXT NOT NULL,
36
+ command_json TEXT NOT NULL,
37
+ command_display TEXT NOT NULL,
38
+ cwd TEXT NOT NULL,
39
+ platform_name TEXT NOT NULL,
40
+ created_at TEXT NOT NULL,
41
+ started_at TEXT,
42
+ ended_at TEXT,
43
+ duration_s REAL,
44
+ exit_code INTEGER,
45
+ interrupted INTEGER NOT NULL DEFAULT 0,
46
+ error TEXT,
47
+ env_diff_json TEXT NOT NULL DEFAULT '{}',
48
+ options_json TEXT NOT NULL DEFAULT '{}'
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS events (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
54
+ kind TEXT NOT NULL,
55
+ occurred_at TEXT NOT NULL,
56
+ source TEXT NOT NULL,
57
+ payload_json TEXT NOT NULL
58
+ );
59
+
60
+ CREATE INDEX IF NOT EXISTS idx_events_session_kind
61
+ ON events(session_id, kind);
62
+
63
+ CREATE TABLE IF NOT EXISTS collectors (
64
+ session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
65
+ name TEXT NOT NULL,
66
+ state TEXT NOT NULL,
67
+ message TEXT,
68
+ details_json TEXT NOT NULL DEFAULT '{}',
69
+ started_at TEXT,
70
+ stopped_at TEXT,
71
+ PRIMARY KEY (session_id, name)
72
+ );
73
+ """
74
+
75
+
76
+ def _iso(value: datetime) -> str:
77
+ if value.tzinfo is None:
78
+ value = value.replace(tzinfo=UTC)
79
+ return value.astimezone(UTC).isoformat(timespec="microseconds")
80
+
81
+
82
+ def _datetime(value: str | None) -> datetime | None:
83
+ return datetime.fromisoformat(value) if value else None
84
+
85
+
86
+ def _json(value: object) -> str:
87
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
88
+
89
+
90
+ def _load_json(value: str) -> JSONValue:
91
+ try:
92
+ loaded: JSONValue = json.loads(value)
93
+ except json.JSONDecodeError as exc:
94
+ raise StorageError(f"corrupt JSON stored in the database: {exc}") from exc
95
+ return loaded
96
+
97
+
98
+ class Storage:
99
+ """Small SQLite repository with schema versioning and cascading deletes."""
100
+
101
+ def __init__(self, path: Path) -> None:
102
+ self.path = Path(path).expanduser()
103
+ try:
104
+ self.path.parent.mkdir(parents=True, exist_ok=True)
105
+ except OSError as exc:
106
+ raise StorageError(
107
+ f"cannot create database directory {self.path.parent}: {exc}"
108
+ ) from exc
109
+
110
+ try:
111
+ self._connection = sqlite3.connect(self.path, timeout=30)
112
+ self._connection.row_factory = sqlite3.Row
113
+ self._connection.execute("PRAGMA foreign_keys = ON")
114
+ self._connection.execute("PRAGMA busy_timeout = 30000")
115
+ with contextlib.suppress(sqlite3.DatabaseError):
116
+ self._connection.execute("PRAGMA journal_mode = WAL")
117
+ self._initialize()
118
+ self._restrict_permissions()
119
+ except sqlite3.Error as exc:
120
+ raise StorageError(f"cannot open database {self.path}: {exc}") from exc
121
+
122
+ def _initialize(self) -> None:
123
+ with self._connection:
124
+ self._connection.executescript(_SCHEMA)
125
+ row = self._connection.execute(
126
+ "SELECT value FROM meta WHERE key = 'schema_version'"
127
+ ).fetchone()
128
+ if row is None:
129
+ self._connection.execute(
130
+ "INSERT INTO meta(key, value) VALUES ('schema_version', ?)",
131
+ (str(SCHEMA_VERSION),),
132
+ )
133
+ return
134
+ stored = int(row["value"])
135
+ if stored > SCHEMA_VERSION:
136
+ raise StorageError(
137
+ f"database schema {stored} is newer than this CmdScope build "
138
+ f"({SCHEMA_VERSION}); upgrade CmdScope"
139
+ )
140
+
141
+ def _restrict_permissions(self) -> None:
142
+ if os.name == "nt":
143
+ return
144
+ try:
145
+ os.chmod(self.path, 0o600)
146
+ os.chmod(self.path.parent, 0o700)
147
+ except OSError:
148
+ pass
149
+
150
+ def close(self) -> None:
151
+ with contextlib.suppress(sqlite3.Error):
152
+ self._connection.close()
153
+
154
+ def __enter__(self) -> Storage:
155
+ return self
156
+
157
+ def __exit__(self, *_exc: object) -> None:
158
+ self.close()
159
+
160
+ def schema_version(self) -> int:
161
+ row = self._connection.execute(
162
+ "SELECT value FROM meta WHERE key = 'schema_version'"
163
+ ).fetchone()
164
+ return int(row["value"]) if row else 0
165
+
166
+ def quick_check(self) -> str:
167
+ row = self._connection.execute("PRAGMA quick_check").fetchone()
168
+ return str(row[0]) if row else "unknown"
169
+
170
+ def create_session(
171
+ self,
172
+ *,
173
+ command: Sequence[str],
174
+ command_display: str,
175
+ cwd: str,
176
+ platform_name: str,
177
+ created_at: datetime,
178
+ env_diff: dict[str, JSONValue],
179
+ options: dict[str, JSONValue],
180
+ ) -> int:
181
+ try:
182
+ with self._connection:
183
+ cursor = self._connection.execute(
184
+ """
185
+ INSERT INTO sessions(
186
+ status, command_json, command_display, cwd, platform_name,
187
+ created_at, env_diff_json, options_json
188
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
189
+ """,
190
+ (
191
+ str(SessionStatus.RUNNING),
192
+ _json(list(command)),
193
+ command_display,
194
+ cwd,
195
+ platform_name,
196
+ _iso(created_at),
197
+ _json(env_diff),
198
+ _json(options),
199
+ ),
200
+ )
201
+ rowid = cursor.lastrowid
202
+ if rowid is None:
203
+ raise StorageError("SQLite did not return a row id")
204
+ return int(rowid)
205
+ except sqlite3.Error as exc:
206
+ raise StorageError(f"cannot create session: {exc}") from exc
207
+
208
+ def mark_command_started(self, session_id: int, started_at: datetime) -> None:
209
+ self._execute(
210
+ "UPDATE sessions SET started_at = ? WHERE id = ?",
211
+ (_iso(started_at), session_id),
212
+ )
213
+
214
+ def finish_session(
215
+ self,
216
+ session_id: int,
217
+ *,
218
+ status: SessionStatus,
219
+ ended_at: datetime,
220
+ duration_s: float,
221
+ exit_code: int | None,
222
+ interrupted: bool,
223
+ error: str | None,
224
+ ) -> None:
225
+ self._execute(
226
+ """
227
+ UPDATE sessions
228
+ SET status = ?, ended_at = ?, duration_s = ?, exit_code = ?,
229
+ interrupted = ?, error = ?
230
+ WHERE id = ?
231
+ """,
232
+ (
233
+ str(status),
234
+ _iso(ended_at),
235
+ duration_s,
236
+ exit_code,
237
+ 1 if interrupted else 0,
238
+ error,
239
+ session_id,
240
+ ),
241
+ )
242
+
243
+ def set_collector_statuses(self, session_id: int, statuses: Iterable[CollectorStatus]) -> None:
244
+ try:
245
+ with self._connection:
246
+ self._connection.execute(
247
+ "DELETE FROM collectors WHERE session_id = ?", (session_id,)
248
+ )
249
+ self._connection.executemany(
250
+ """
251
+ INSERT INTO collectors(
252
+ session_id, name, state, message, details_json,
253
+ started_at, stopped_at
254
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
255
+ """,
256
+ [
257
+ (
258
+ session_id,
259
+ status.name,
260
+ str(status.state),
261
+ status.message,
262
+ _json(status.details),
263
+ _iso(status.started_at) if status.started_at else None,
264
+ _iso(status.stopped_at) if status.stopped_at else None,
265
+ )
266
+ for status in statuses
267
+ ],
268
+ )
269
+ except sqlite3.Error as exc:
270
+ raise StorageError(f"cannot store collector status: {exc}") from exc
271
+
272
+ def insert_event(self, session_id: int, event: Event) -> int:
273
+ try:
274
+ with self._connection:
275
+ cursor = self._connection.execute(
276
+ """
277
+ INSERT INTO events(session_id, kind, occurred_at, source, payload_json)
278
+ VALUES (?, ?, ?, ?, ?)
279
+ """,
280
+ (
281
+ session_id,
282
+ str(event.kind),
283
+ _iso(event.occurred_at),
284
+ event.source,
285
+ _json(event.payload),
286
+ ),
287
+ )
288
+ rowid = cursor.lastrowid
289
+ if rowid is None:
290
+ raise StorageError("SQLite did not return a row id")
291
+ return int(rowid)
292
+ except sqlite3.Error as exc:
293
+ raise StorageError(f"cannot store event: {exc}") from exc
294
+
295
+ def get_session(self, session_id: int) -> Session | None:
296
+ if session_id <= 0:
297
+ return None
298
+ row = self._connection.execute(
299
+ "SELECT * FROM sessions WHERE id = ?", (session_id,)
300
+ ).fetchone()
301
+ if row is None:
302
+ return None
303
+ return self._row_to_session(row, self.get_collector_statuses(session_id))
304
+
305
+ def require_session(self, reference: str) -> Session:
306
+ try:
307
+ session_id = parse_session_ref(reference)
308
+ except ValueError as exc:
309
+ raise SessionNotFoundError(f"invalid session reference: {reference!r}") from exc
310
+ session = self.get_session(session_id)
311
+ if session is None:
312
+ raise SessionNotFoundError(f"session not found: {reference}")
313
+ return session
314
+
315
+ def get_collector_statuses(self, session_id: int) -> tuple[CollectorStatus, ...]:
316
+ rows = self._connection.execute(
317
+ "SELECT * FROM collectors WHERE session_id = ? ORDER BY name",
318
+ (session_id,),
319
+ ).fetchall()
320
+ statuses: list[CollectorStatus] = []
321
+ for row in rows:
322
+ try:
323
+ state = CollectorState(row["state"])
324
+ except ValueError:
325
+ state = CollectorState.DEGRADED
326
+ details = _load_json(row["details_json"])
327
+ statuses.append(
328
+ CollectorStatus(
329
+ name=str(row["name"]),
330
+ state=state,
331
+ message=str(row["message"]) if row["message"] is not None else None,
332
+ details=details if isinstance(details, dict) else {},
333
+ started_at=_datetime(row["started_at"]),
334
+ stopped_at=_datetime(row["stopped_at"]),
335
+ )
336
+ )
337
+ return tuple(statuses)
338
+
339
+ def get_events(
340
+ self,
341
+ session_id: int,
342
+ kinds: Sequence[EventKind] | None = None,
343
+ ) -> list[StoredEvent]:
344
+ sql = "SELECT * FROM events WHERE session_id = ?"
345
+ parameters: list[object] = [session_id]
346
+ if kinds:
347
+ placeholders = ", ".join("?" for _ in kinds)
348
+ sql += f" AND kind IN ({placeholders})"
349
+ parameters.extend(str(kind) for kind in kinds)
350
+ sql += " ORDER BY id"
351
+ rows = self._connection.execute(sql, parameters).fetchall()
352
+ events: list[StoredEvent] = []
353
+ for row in rows:
354
+ payload = _load_json(row["payload_json"])
355
+ if not isinstance(payload, dict):
356
+ raise StorageError(f"event {row['id']} has a non-object payload")
357
+ events.append(
358
+ StoredEvent(
359
+ id=int(row["id"]),
360
+ session_id=int(row["session_id"]),
361
+ kind=EventKind(row["kind"]),
362
+ occurred_at=datetime.fromisoformat(row["occurred_at"]),
363
+ source=str(row["source"]),
364
+ payload=payload,
365
+ )
366
+ )
367
+ return events
368
+
369
+ def count_events(self, session_id: int) -> int:
370
+ row = self._connection.execute(
371
+ "SELECT COUNT(*) AS total FROM events WHERE session_id = ?", (session_id,)
372
+ ).fetchone()
373
+ return int(row["total"]) if row else 0
374
+
375
+ def list_sessions(self, limit: int = 50) -> list[Session]:
376
+ if limit < 1:
377
+ raise ValueError("limit must be positive")
378
+ rows = self._connection.execute(
379
+ "SELECT * FROM sessions ORDER BY id DESC LIMIT ?", (limit,)
380
+ ).fetchall()
381
+ return [self._row_to_session(row, ()) for row in rows]
382
+
383
+ def delete_session(self, session_id: int) -> bool:
384
+ try:
385
+ with self._connection:
386
+ cursor = self._connection.execute(
387
+ "DELETE FROM sessions WHERE id = ?", (session_id,)
388
+ )
389
+ return cursor.rowcount > 0
390
+ except sqlite3.Error as exc:
391
+ raise StorageError(f"cannot delete session: {exc}") from exc
392
+
393
+ def prune(self, max_sessions: int) -> int:
394
+ """Delete oldest finished sessions when the configured limit is exceeded."""
395
+
396
+ if max_sessions <= 0:
397
+ return 0
398
+ row = self._connection.execute(
399
+ "SELECT COUNT(*) AS total FROM sessions WHERE status != ?",
400
+ (str(SessionStatus.RUNNING),),
401
+ ).fetchone()
402
+ total = int(row["total"]) if row else 0
403
+ excess = total - max_sessions
404
+ if excess <= 0:
405
+ return 0
406
+ try:
407
+ with self._connection:
408
+ cursor = self._connection.execute(
409
+ """
410
+ DELETE FROM sessions
411
+ WHERE id IN (
412
+ SELECT id FROM sessions
413
+ WHERE status != ?
414
+ ORDER BY id ASC
415
+ LIMIT ?
416
+ )
417
+ """,
418
+ (str(SessionStatus.RUNNING), excess),
419
+ )
420
+ return cursor.rowcount
421
+ except sqlite3.Error as exc:
422
+ raise StorageError(f"cannot prune sessions: {exc}") from exc
423
+
424
+ def storage_size_bytes(self) -> int:
425
+ total = 0
426
+ for suffix in ("", "-wal", "-shm"):
427
+ candidate = Path(f"{self.path}{suffix}")
428
+ with contextlib.suppress(OSError):
429
+ total += candidate.stat().st_size
430
+ return total
431
+
432
+ def _execute(self, sql: str, parameters: Sequence[object]) -> None:
433
+ try:
434
+ with self._connection:
435
+ self._connection.execute(sql, parameters)
436
+ except sqlite3.Error as exc:
437
+ raise StorageError(f"database update failed: {exc}") from exc
438
+
439
+ @staticmethod
440
+ def _row_to_session(row: sqlite3.Row, collectors: tuple[CollectorStatus, ...]) -> Session:
441
+ try:
442
+ command_value = _load_json(row["command_json"])
443
+ env_diff = _load_json(row["env_diff_json"])
444
+ options = _load_json(row["options_json"])
445
+ status = SessionStatus(row["status"])
446
+ except ValueError as exc:
447
+ raise StorageError(f"corrupt session row {row['id']}: {exc}") from exc
448
+ if not isinstance(command_value, list) or not all(
449
+ isinstance(item, str) for item in command_value
450
+ ):
451
+ raise StorageError(f"corrupt command in session {row['id']}")
452
+ return Session(
453
+ id=int(row["id"]),
454
+ status=status,
455
+ command=tuple(str(item) for item in command_value),
456
+ command_display=str(row["command_display"]),
457
+ cwd=str(row["cwd"]),
458
+ platform_name=str(row["platform_name"]),
459
+ created_at=datetime.fromisoformat(row["created_at"]),
460
+ started_at=_datetime(row["started_at"]),
461
+ ended_at=_datetime(row["ended_at"]),
462
+ duration_s=float(row["duration_s"]) if row["duration_s"] is not None else None,
463
+ exit_code=int(row["exit_code"]) if row["exit_code"] is not None else None,
464
+ interrupted=bool(row["interrupted"]),
465
+ error=str(row["error"]) if row["error"] is not None else None,
466
+ env_diff=env_diff if isinstance(env_diff, dict) else {},
467
+ options=options if isinstance(options, dict) else {},
468
+ collectors=collectors,
469
+ )
@@ -0,0 +1,105 @@
1
+ """Event writer thread with bounded session storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import queue
6
+ import sqlite3
7
+ import threading
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from cmdscope.core.events import Event
12
+ from cmdscope.errors import StorageError
13
+ from cmdscope.storage.sqlite import _iso, _json
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class EventWriterStats:
18
+ written: int
19
+ dropped: int
20
+ truncated: bool
21
+
22
+
23
+ class EventWriter:
24
+ """Serialize collector events into SQLite without blocking collectors.
25
+
26
+ A single writer thread owns its SQLite connection. Collectors only enqueue
27
+ typed events, so filesystem observers, process pollers, and network pollers
28
+ never contend for the database or for each other's locks.
29
+ """
30
+
31
+ def __init__(self, database: Path, session_id: int, max_events: int) -> None:
32
+ if max_events < 1:
33
+ raise ValueError("max_events must be at least 1")
34
+ self._database = Path(database)
35
+ self._session_id = session_id
36
+ self._max_events = max_events
37
+ self._queue: queue.Queue[Event | None] = queue.Queue()
38
+ self._thread = threading.Thread(target=self._run, name="cmdscope-event-writer", daemon=True)
39
+ self._closed = False
40
+ self._written = 0
41
+ self._dropped = 0
42
+ self._error: BaseException | None = None
43
+
44
+ def start(self) -> None:
45
+ self._thread.start()
46
+
47
+ def emit(self, event: Event) -> None:
48
+ if self._closed:
49
+ return
50
+ self._queue.put(event)
51
+
52
+ def close(self) -> EventWriterStats:
53
+ if not self._closed:
54
+ self._closed = True
55
+ self._queue.put(None)
56
+ self._thread.join(timeout=30)
57
+ if self._thread.is_alive():
58
+ raise StorageError("event writer did not finish within 30 seconds")
59
+ if self._error is not None:
60
+ raise StorageError(f"event writer failed: {self._error}") from self._error
61
+ return EventWriterStats(
62
+ written=self._written,
63
+ dropped=self._dropped,
64
+ truncated=self._dropped > 0,
65
+ )
66
+
67
+ def _run(self) -> None:
68
+ connection: sqlite3.Connection | None = None
69
+ try:
70
+ connection = sqlite3.connect(self._database, timeout=30)
71
+ connection.execute("PRAGMA busy_timeout = 30000")
72
+ connection.execute("PRAGMA foreign_keys = ON")
73
+ while True:
74
+ try:
75
+ event = self._queue.get(timeout=0.5)
76
+ except queue.Empty:
77
+ connection.commit()
78
+ continue
79
+ if event is None:
80
+ break
81
+ if self._written >= self._max_events:
82
+ self._dropped += 1
83
+ continue
84
+ connection.execute(
85
+ """
86
+ INSERT INTO events(session_id, kind, occurred_at, source, payload_json)
87
+ VALUES (?, ?, ?, ?, ?)
88
+ """,
89
+ (
90
+ self._session_id,
91
+ str(event.kind),
92
+ _iso(event.occurred_at),
93
+ event.source,
94
+ _json(event.payload),
95
+ ),
96
+ )
97
+ self._written += 1
98
+ if self._written % 128 == 0:
99
+ connection.commit()
100
+ connection.commit()
101
+ except BaseException as exc:
102
+ self._error = exc
103
+ finally:
104
+ if connection is not None:
105
+ connection.close()