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/__init__.py +3 -0
- vallignus/auth.py +699 -0
- vallignus/cli.py +780 -0
- vallignus/identity/__init__.py +5 -0
- vallignus/identity/chrome.py +47 -0
- vallignus/identity/manager.py +175 -0
- vallignus/logger.py +86 -0
- vallignus/proxy.py +122 -0
- vallignus/rules.py +90 -0
- vallignus/sessions.py +529 -0
- vallignus-0.4.0.dist-info/METADATA +250 -0
- vallignus-0.4.0.dist-info/RECORD +15 -0
- vallignus-0.4.0.dist-info/WHEEL +5 -0
- vallignus-0.4.0.dist-info/entry_points.txt +2 -0
- vallignus-0.4.0.dist-info/top_level.txt +1 -0
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")
|