vallignus 0.4.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.
vallignus/sessions.py ADDED
@@ -0,0 +1,529 @@
1
+ """Vallignus Sessions - Agent Runtime with sessions and event logging
2
+
3
+ Sprint 0 Features:
4
+ - Session creation with unique IDs (YYYYMMDD-HHMMSS-<random6>)
5
+ - Structured event logging (JSONL)
6
+ - Session metadata (session.json)
7
+ - stdout/stderr capture
8
+ - Session listing and replay
9
+
10
+ Sprint 1 Features:
11
+ - Runtime caps and termination controls
12
+ - Output line limits
13
+ - Request counting (firewall mode)
14
+ - Termination event logging
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import random
20
+ import string
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ import time
25
+ from dataclasses import dataclass, asdict, field
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Dict, List, Optional, Any, Callable
29
+
30
+
31
+ # Storage paths
32
+ VALLIGNUS_DIR = Path.home() / ".vallignus"
33
+ SESSIONS_DIR = VALLIGNUS_DIR / "sessions"
34
+
35
+
36
+ @dataclass
37
+ class SessionMetadata:
38
+ """Session metadata stored in session.json"""
39
+ session_id: str
40
+ started_at_iso: str
41
+ command: List[str]
42
+ cwd: str
43
+ env_summary: Dict[str, str]
44
+ exit_code: Optional[int] = None
45
+ duration_ms: Optional[int] = None
46
+ finished_at_iso: Optional[str] = None
47
+ stdout_lines: int = 0
48
+ stderr_lines: int = 0
49
+ # Sprint 1: Termination tracking
50
+ termination_reason: Optional[str] = None # "max_runtime", "max_output_lines", "max_requests"
51
+ termination_limit_value: Optional[int] = None
52
+ termination_observed_value: Optional[int] = None
53
+ # Sprint 1: Request counters (firewall mode)
54
+ allowed_requests: Optional[int] = None
55
+ denied_requests: Optional[int] = None
56
+ total_requests: Optional[int] = None
57
+
58
+
59
+ @dataclass
60
+ class SessionEvent:
61
+ """A single event in the session event stream"""
62
+ ts_ms: int
63
+ session_id: str
64
+ type: str
65
+ line: Optional[str] = None
66
+ exit_code: Optional[int] = None
67
+ command: Optional[List[str]] = None
68
+ cwd: Optional[str] = None
69
+ duration_ms: Optional[int] = None
70
+ # Sprint 1: Termination event fields
71
+ reason: Optional[str] = None
72
+ limit_value: Optional[int] = None
73
+ observed_value: Optional[int] = None
74
+
75
+ def to_json(self) -> str:
76
+ """Serialize event to JSON, excluding None values"""
77
+ data = {k: v for k, v in asdict(self).items() if v is not None}
78
+ return json.dumps(data, separators=(',', ':'))
79
+
80
+
81
+ def generate_session_id() -> str:
82
+ """
83
+ Generate a unique session ID in format: YYYYMMDD-HHMMSS-<random6>
84
+
85
+ Example: 20250124-153045-a1b2c3
86
+ """
87
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
88
+ random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
89
+ return f"{timestamp}-{random_suffix}"
90
+
91
+
92
+ def get_session_dir(session_id: str) -> Path:
93
+ """Get the directory path for a session"""
94
+ return SESSIONS_DIR / session_id
95
+
96
+
97
+ def get_safe_env_summary(env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
98
+ """
99
+ Extract safe environment variables for logging.
100
+ Only includes non-sensitive keys.
101
+ """
102
+ if env is None:
103
+ env = dict(os.environ)
104
+
105
+ # Safe keys to include
106
+ safe_prefixes = ('PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_',
107
+ 'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_', 'NODE_', 'NPM_')
108
+ safe_exact = {'PWD', 'OLDPWD', 'SHLVL', 'HOSTNAME', 'LOGNAME'}
109
+
110
+ result = {}
111
+ for key, value in env.items():
112
+ if key in safe_exact or any(key.startswith(p) for p in safe_prefixes):
113
+ # Truncate long values
114
+ if len(value) > 200:
115
+ value = value[:200] + "..."
116
+ result[key] = value
117
+
118
+ return result
119
+
120
+
121
+ class EventLogger:
122
+ """Append-only event logger that writes to events.jsonl"""
123
+
124
+ def __init__(self, session_dir: Path, session_id: str):
125
+ self.session_dir = session_dir
126
+ self.session_id = session_id
127
+ self.events_file = session_dir / "events.jsonl"
128
+ self._lock = threading.Lock()
129
+
130
+ def _now_ms(self) -> int:
131
+ """Get current timestamp in milliseconds"""
132
+ return int(time.time() * 1000)
133
+
134
+ def log(self, event_type: str, **kwargs) -> SessionEvent:
135
+ """Log an event to the JSONL file"""
136
+ event = SessionEvent(
137
+ ts_ms=self._now_ms(),
138
+ session_id=self.session_id,
139
+ type=event_type,
140
+ **kwargs
141
+ )
142
+
143
+ with self._lock:
144
+ with open(self.events_file, 'a') as f:
145
+ f.write(event.to_json() + '\n')
146
+
147
+ return event
148
+
149
+ def run_started(self, command: List[str], cwd: str) -> SessionEvent:
150
+ """Log run_started event"""
151
+ return self.log('run_started', command=command, cwd=cwd)
152
+
153
+ def process_started(self) -> SessionEvent:
154
+ """Log process_started event"""
155
+ return self.log('process_started')
156
+
157
+ def stdout_line(self, line: str) -> SessionEvent:
158
+ """Log stdout_line event"""
159
+ return self.log('stdout_line', line=line)
160
+
161
+ def stderr_line(self, line: str) -> SessionEvent:
162
+ """Log stderr_line event"""
163
+ return self.log('stderr_line', line=line)
164
+
165
+ def process_exited(self, exit_code: int) -> SessionEvent:
166
+ """Log process_exited event"""
167
+ return self.log('process_exited', exit_code=exit_code)
168
+
169
+ def run_terminated(self, reason: str, limit_value: int, observed_value: int) -> SessionEvent:
170
+ """Log run_terminated event when process is killed due to limits"""
171
+ return self.log('run_terminated', reason=reason, limit_value=limit_value, observed_value=observed_value)
172
+
173
+ def run_finished(self, duration_ms: int) -> SessionEvent:
174
+ """Log run_finished event"""
175
+ return self.log('run_finished', duration_ms=duration_ms)
176
+
177
+
178
+ class SessionManager:
179
+ """Manages session lifecycle, directories, and metadata"""
180
+
181
+ def __init__(self, session_id: Optional[str] = None):
182
+ self.session_id = session_id or generate_session_id()
183
+ self.session_dir = get_session_dir(self.session_id)
184
+ self.event_logger: Optional[EventLogger] = None
185
+ self.metadata: Optional[SessionMetadata] = None
186
+ self._start_time: Optional[float] = None
187
+ self._stdout_lines = 0
188
+ self._stderr_lines = 0
189
+ self._stdout_file: Optional[Any] = None
190
+ self._stderr_file: Optional[Any] = None
191
+ # Sprint 1: Termination tracking
192
+ self._termination_reason: Optional[str] = None
193
+ self._termination_limit_value: Optional[int] = None
194
+ self._termination_observed_value: Optional[int] = None
195
+ # Sprint 1: Request counters
196
+ self._allowed_requests: int = 0
197
+ self._denied_requests: int = 0
198
+
199
+ @property
200
+ def total_output_lines(self) -> int:
201
+ """Get total output lines (stdout + stderr)"""
202
+ return self._stdout_lines + self._stderr_lines
203
+
204
+ @property
205
+ def elapsed_seconds(self) -> float:
206
+ """Get elapsed time since session started"""
207
+ if self._start_time is None:
208
+ return 0.0
209
+ return time.time() - self._start_time
210
+
211
+ def create(self, command: List[str], cwd: Optional[str] = None,
212
+ env: Optional[Dict[str, str]] = None) -> 'SessionManager':
213
+ """Create a new session directory and initialize files"""
214
+ # Ensure sessions directory exists
215
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
216
+
217
+ # Create session directory
218
+ self.session_dir.mkdir(parents=True, exist_ok=True)
219
+
220
+ # Initialize event logger
221
+ self.event_logger = EventLogger(self.session_dir, self.session_id)
222
+
223
+ # Create initial metadata
224
+ cwd = cwd or os.getcwd()
225
+ self.metadata = SessionMetadata(
226
+ session_id=self.session_id,
227
+ started_at_iso=datetime.now().isoformat(),
228
+ command=command,
229
+ cwd=cwd,
230
+ env_summary=get_safe_env_summary(env)
231
+ )
232
+
233
+ # Open stdout/stderr log files
234
+ self._stdout_file = open(self.session_dir / "stdout.log", 'w')
235
+ self._stderr_file = open(self.session_dir / "stderr.log", 'w')
236
+
237
+ # Write initial session.json
238
+ self._save_metadata()
239
+
240
+ # Record start time
241
+ self._start_time = time.time()
242
+
243
+ # Log run_started event
244
+ self.event_logger.run_started(command, cwd)
245
+
246
+ return self
247
+
248
+ def _save_metadata(self):
249
+ """Save current metadata to session.json, excluding None values"""
250
+ data = {k: v for k, v in asdict(self.metadata).items() if v is not None}
251
+ with open(self.session_dir / "session.json", 'w') as f:
252
+ json.dump(data, f, indent=2)
253
+
254
+ def log_stdout(self, line: str) -> int:
255
+ """Log a stdout line. Returns total output lines."""
256
+ self._stdout_lines += 1
257
+ if self.event_logger:
258
+ self.event_logger.stdout_line(line)
259
+ if self._stdout_file:
260
+ self._stdout_file.write(line + '\n')
261
+ self._stdout_file.flush()
262
+ return self.total_output_lines
263
+
264
+ def log_stderr(self, line: str) -> int:
265
+ """Log a stderr line. Returns total output lines."""
266
+ self._stderr_lines += 1
267
+ if self.event_logger:
268
+ self.event_logger.stderr_line(line)
269
+ if self._stderr_file:
270
+ self._stderr_file.write(line + '\n')
271
+ self._stderr_file.flush()
272
+ return self.total_output_lines
273
+
274
+ def process_started(self):
275
+ """Log that the subprocess has started"""
276
+ if self.event_logger:
277
+ self.event_logger.process_started()
278
+
279
+ def terminate(self, reason: str, limit_value: int, observed_value: int):
280
+ """
281
+ Record that the session was terminated due to a limit.
282
+
283
+ Args:
284
+ reason: One of "max_runtime", "max_output_lines", "max_requests"
285
+ limit_value: The configured limit
286
+ observed_value: The actual value that triggered termination
287
+ """
288
+ self._termination_reason = reason
289
+ self._termination_limit_value = limit_value
290
+ self._termination_observed_value = observed_value
291
+
292
+ if self.event_logger:
293
+ self.event_logger.run_terminated(reason, limit_value, observed_value)
294
+
295
+ def set_request_counts(self, allowed: int, denied: int):
296
+ """Set request counters from firewall mode"""
297
+ self._allowed_requests = allowed
298
+ self._denied_requests = denied
299
+
300
+ def finish(self, exit_code: int):
301
+ """Finalize the session with exit code and duration"""
302
+ if self._start_time is None:
303
+ return
304
+
305
+ duration_ms = int((time.time() - self._start_time) * 1000)
306
+
307
+ # Log process_exited and run_finished events
308
+ if self.event_logger:
309
+ self.event_logger.process_exited(exit_code)
310
+ self.event_logger.run_finished(duration_ms)
311
+
312
+ # Update and save metadata
313
+ if self.metadata:
314
+ self.metadata.exit_code = exit_code
315
+ self.metadata.duration_ms = duration_ms
316
+ self.metadata.finished_at_iso = datetime.now().isoformat()
317
+ self.metadata.stdout_lines = self._stdout_lines
318
+ self.metadata.stderr_lines = self._stderr_lines
319
+ # Sprint 1: Termination info
320
+ if self._termination_reason:
321
+ self.metadata.termination_reason = self._termination_reason
322
+ self.metadata.termination_limit_value = self._termination_limit_value
323
+ self.metadata.termination_observed_value = self._termination_observed_value
324
+ # Sprint 1: Request counters (only if firewall mode was active)
325
+ if self._allowed_requests > 0 or self._denied_requests > 0:
326
+ self.metadata.allowed_requests = self._allowed_requests
327
+ self.metadata.denied_requests = self._denied_requests
328
+ self.metadata.total_requests = self._allowed_requests + self._denied_requests
329
+ self._save_metadata()
330
+
331
+ # Close log files
332
+ if self._stdout_file:
333
+ self._stdout_file.close()
334
+ if self._stderr_file:
335
+ self._stderr_file.close()
336
+
337
+ @staticmethod
338
+ def load(session_id: str) -> Optional[SessionMetadata]:
339
+ """Load session metadata from disk"""
340
+ session_dir = get_session_dir(session_id)
341
+ session_json = session_dir / "session.json"
342
+
343
+ if not session_json.exists():
344
+ return None
345
+
346
+ try:
347
+ with open(session_json, 'r') as f:
348
+ data = json.load(f)
349
+ # Handle missing optional fields by filtering to known fields
350
+ known_fields = {f.name for f in SessionMetadata.__dataclass_fields__.values()}
351
+ filtered_data = {k: v for k, v in data.items() if k in known_fields}
352
+ return SessionMetadata(**filtered_data)
353
+ except Exception:
354
+ return None
355
+
356
+ @staticmethod
357
+ def load_events(session_id: str) -> List[Dict[str, Any]]:
358
+ """Load all events for a session"""
359
+ session_dir = get_session_dir(session_id)
360
+ events_file = session_dir / "events.jsonl"
361
+
362
+ if not events_file.exists():
363
+ return []
364
+
365
+ events = []
366
+ with open(events_file, 'r') as f:
367
+ for line in f:
368
+ line = line.strip()
369
+ if line:
370
+ try:
371
+ events.append(json.loads(line))
372
+ except json.JSONDecodeError:
373
+ pass
374
+ return events
375
+
376
+ @staticmethod
377
+ def list_sessions(limit: int = 20) -> List[SessionMetadata]:
378
+ """
379
+ List recent sessions, sorted by most recent first.
380
+ Returns up to `limit` sessions.
381
+ """
382
+ if not SESSIONS_DIR.exists():
383
+ return []
384
+
385
+ sessions = []
386
+
387
+ for session_dir in SESSIONS_DIR.iterdir():
388
+ if session_dir.is_dir():
389
+ metadata = SessionManager.load(session_dir.name)
390
+ if metadata:
391
+ sessions.append(metadata)
392
+
393
+ # Sort by started_at_iso descending (most recent first)
394
+ sessions.sort(key=lambda s: s.started_at_iso, reverse=True)
395
+
396
+ return sessions[:limit]
397
+
398
+
399
+ def run_with_session(command: List[str], env: Optional[Dict[str, str]] = None,
400
+ stdout_callback: Optional[Callable[[str], None]] = None,
401
+ stderr_callback: Optional[Callable[[str], None]] = None) -> tuple:
402
+ """
403
+ Run a command with session tracking.
404
+
405
+ Args:
406
+ command: Command to run as a list of strings
407
+ env: Environment variables to use (defaults to os.environ)
408
+ stdout_callback: Optional callback for each stdout line
409
+ stderr_callback: Optional callback for each stderr line
410
+
411
+ Returns:
412
+ (session_id, exit_code)
413
+ """
414
+ session = SessionManager().create(command, env=env)
415
+
416
+ if env is None:
417
+ env = os.environ.copy()
418
+
419
+ try:
420
+ process = subprocess.Popen(
421
+ command,
422
+ stdout=subprocess.PIPE,
423
+ stderr=subprocess.PIPE,
424
+ env=env,
425
+ text=True,
426
+ bufsize=1
427
+ )
428
+
429
+ session.process_started()
430
+
431
+ # Create threads to read stdout and stderr
432
+ def read_stdout():
433
+ for line in iter(process.stdout.readline, ''):
434
+ line = line.rstrip('\n\r')
435
+ session.log_stdout(line)
436
+ if stdout_callback:
437
+ stdout_callback(line)
438
+
439
+ def read_stderr():
440
+ for line in iter(process.stderr.readline, ''):
441
+ line = line.rstrip('\n\r')
442
+ session.log_stderr(line)
443
+ if stderr_callback:
444
+ stderr_callback(line)
445
+
446
+ stdout_thread = threading.Thread(target=read_stdout, daemon=True)
447
+ stderr_thread = threading.Thread(target=read_stderr, daemon=True)
448
+
449
+ stdout_thread.start()
450
+ stderr_thread.start()
451
+
452
+ # Wait for process to complete
453
+ exit_code = process.wait()
454
+
455
+ # Wait for threads to finish reading
456
+ stdout_thread.join(timeout=1.0)
457
+ stderr_thread.join(timeout=1.0)
458
+
459
+ session.finish(exit_code)
460
+
461
+ return session.session_id, exit_code
462
+
463
+ except Exception as e:
464
+ session.finish(-1)
465
+ raise
466
+
467
+
468
+ def replay_session(session_id: str, output_callback: Optional[Callable[[str, str, int], None]] = None):
469
+ """
470
+ Replay a session's events to the console.
471
+
472
+ Args:
473
+ session_id: The session to replay
474
+ output_callback: Optional callback(event_type, line, ts_ms) for custom output handling
475
+ """
476
+ events = SessionManager.load_events(session_id)
477
+
478
+ if not events:
479
+ raise ValueError(f"No events found for session {session_id}")
480
+
481
+ start_ts = None
482
+
483
+ for event in events:
484
+ event_type = event.get('type')
485
+ ts_ms = event.get('ts_ms', 0)
486
+
487
+ if start_ts is None:
488
+ start_ts = ts_ms
489
+
490
+ relative_ms = ts_ms - start_ts
491
+
492
+ if event_type in ('stdout_line', 'stderr_line'):
493
+ line = event.get('line', '')
494
+ if output_callback:
495
+ output_callback(event_type, line, relative_ms)
496
+ else:
497
+ # Default output: print with timestamp
498
+ prefix = '[stdout]' if event_type == 'stdout_line' else '[stderr]'
499
+ timestamp = f"[{relative_ms/1000:.3f}s]"
500
+ print(f"{timestamp} {prefix} {line}")
501
+
502
+ elif event_type == 'run_started':
503
+ cmd = event.get('command', [])
504
+ cwd = event.get('cwd', '')
505
+ if output_callback:
506
+ output_callback(event_type, f"Command: {' '.join(cmd)}", relative_ms)
507
+ else:
508
+ print(f"[{relative_ms/1000:.3f}s] [run_started] Command: {' '.join(cmd)}")
509
+ print(f"[{relative_ms/1000:.3f}s] [run_started] CWD: {cwd}")
510
+
511
+ elif event_type == 'process_started':
512
+ if output_callback:
513
+ output_callback(event_type, 'Process started', relative_ms)
514
+ else:
515
+ print(f"[{relative_ms/1000:.3f}s] [process_started]")
516
+
517
+ elif event_type == 'process_exited':
518
+ exit_code = event.get('exit_code', 'unknown')
519
+ if output_callback:
520
+ output_callback(event_type, f"Exit code: {exit_code}", relative_ms)
521
+ else:
522
+ print(f"[{relative_ms/1000:.3f}s] [process_exited] Exit code: {exit_code}")
523
+
524
+ elif event_type == 'run_finished':
525
+ duration = event.get('duration_ms', 0)
526
+ if output_callback:
527
+ output_callback(event_type, f"Duration: {duration}ms", relative_ms)
528
+ else:
529
+ print(f"[{relative_ms/1000:.3f}s] [run_finished] Duration: {duration}ms")