overcode 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.
- overcode/__init__.py +5 -0
- overcode/cli.py +812 -0
- overcode/config.py +72 -0
- overcode/daemon.py +1184 -0
- overcode/daemon_claude_skill.md +180 -0
- overcode/daemon_state.py +113 -0
- overcode/data_export.py +257 -0
- overcode/dependency_check.py +227 -0
- overcode/exceptions.py +219 -0
- overcode/history_reader.py +448 -0
- overcode/implementations.py +214 -0
- overcode/interfaces.py +49 -0
- overcode/launcher.py +434 -0
- overcode/logging_config.py +193 -0
- overcode/mocks.py +152 -0
- overcode/monitor_daemon.py +808 -0
- overcode/monitor_daemon_state.py +358 -0
- overcode/pid_utils.py +225 -0
- overcode/presence_logger.py +454 -0
- overcode/protocols.py +143 -0
- overcode/session_manager.py +606 -0
- overcode/settings.py +412 -0
- overcode/standing_instructions.py +276 -0
- overcode/status_constants.py +190 -0
- overcode/status_detector.py +339 -0
- overcode/status_history.py +164 -0
- overcode/status_patterns.py +264 -0
- overcode/summarizer_client.py +136 -0
- overcode/summarizer_component.py +312 -0
- overcode/supervisor_daemon.py +1000 -0
- overcode/supervisor_layout.sh +50 -0
- overcode/tmux_manager.py +228 -0
- overcode/tui.py +2549 -0
- overcode/tui_helpers.py +495 -0
- overcode/web_api.py +279 -0
- overcode/web_server.py +138 -0
- overcode/web_templates.py +563 -0
- overcode-0.1.0.dist-info/METADATA +87 -0
- overcode-0.1.0.dist-info/RECORD +43 -0
- overcode-0.1.0.dist-info/WHEEL +5 -0
- overcode-0.1.0.dist-info/entry_points.txt +2 -0
- overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- overcode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session state management for Overcode.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Dict, List, Optional
|
|
10
|
+
from dataclasses import dataclass, asdict, field, fields
|
|
11
|
+
import uuid
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from .exceptions import StateWriteError
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import fcntl
|
|
18
|
+
HAS_FCNTL = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
# Windows doesn't have fcntl
|
|
21
|
+
HAS_FCNTL = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SessionStats:
|
|
26
|
+
"""Runtime statistics for a Claude session"""
|
|
27
|
+
interaction_count: int = 0
|
|
28
|
+
estimated_cost_usd: float = 0.0
|
|
29
|
+
total_tokens: int = 0
|
|
30
|
+
operation_times: List[float] = field(default_factory=list) # seconds per operation
|
|
31
|
+
steers_count: int = 0 # number of overcode interventions
|
|
32
|
+
last_activity: Optional[str] = None # ISO timestamp
|
|
33
|
+
current_task: str = "Initializing..." # one-sentence description
|
|
34
|
+
|
|
35
|
+
# Token breakdown (persisted from Claude Code history)
|
|
36
|
+
input_tokens: int = 0
|
|
37
|
+
output_tokens: int = 0
|
|
38
|
+
cache_creation_tokens: int = 0
|
|
39
|
+
cache_read_tokens: int = 0
|
|
40
|
+
last_stats_update: Optional[str] = None # ISO timestamp of last stats sync
|
|
41
|
+
|
|
42
|
+
# State tracking
|
|
43
|
+
current_state: str = "running" # running, no_instructions, waiting_supervisor, waiting_user
|
|
44
|
+
state_since: Optional[str] = None # ISO timestamp when current state started
|
|
45
|
+
green_time_seconds: float = 0.0 # time spent in "running" state
|
|
46
|
+
non_green_time_seconds: float = 0.0 # time spent in non-running states
|
|
47
|
+
last_time_accumulation: Optional[str] = None # ISO timestamp when times were last accumulated
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return asdict(self)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: dict) -> 'SessionStats':
|
|
54
|
+
"""Create SessionStats from dict, handling unknown/invalid fields gracefully."""
|
|
55
|
+
# Get valid field names from the dataclass
|
|
56
|
+
valid_fields = {f.name for f in fields(cls)}
|
|
57
|
+
# Filter to only known fields to avoid TypeError on unknown keys
|
|
58
|
+
filtered = {k: v for k, v in data.items() if k in valid_fields}
|
|
59
|
+
try:
|
|
60
|
+
return cls(**filtered)
|
|
61
|
+
except TypeError:
|
|
62
|
+
# If still failing (wrong types), return defaults
|
|
63
|
+
return cls()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Session:
|
|
68
|
+
"""Represents a Claude session"""
|
|
69
|
+
id: str
|
|
70
|
+
name: str
|
|
71
|
+
tmux_session: str
|
|
72
|
+
tmux_window: int
|
|
73
|
+
command: List[str]
|
|
74
|
+
start_directory: Optional[str]
|
|
75
|
+
start_time: str
|
|
76
|
+
|
|
77
|
+
# Git context
|
|
78
|
+
repo_name: Optional[str] = None
|
|
79
|
+
branch: Optional[str] = None
|
|
80
|
+
|
|
81
|
+
# Management
|
|
82
|
+
status: str = "running"
|
|
83
|
+
permissiveness_mode: str = "normal" # normal, permissive, bypass
|
|
84
|
+
standing_instructions: str = "" # e.g., "keep herding it on to completion"
|
|
85
|
+
standing_instructions_preset: Optional[str] = None # preset name if using library preset
|
|
86
|
+
standing_orders_complete: bool = False # True when supervisor marks orders as done
|
|
87
|
+
|
|
88
|
+
# Statistics
|
|
89
|
+
stats: SessionStats = field(default_factory=SessionStats)
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict:
|
|
92
|
+
data = asdict(self)
|
|
93
|
+
# Convert stats to dict
|
|
94
|
+
data['stats'] = self.stats.to_dict()
|
|
95
|
+
return data
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, data: dict) -> Optional['Session']:
|
|
99
|
+
"""Create Session from dict, handling unknown/invalid fields gracefully.
|
|
100
|
+
|
|
101
|
+
Returns None if required fields are missing or data is corrupt.
|
|
102
|
+
"""
|
|
103
|
+
# Required fields that must be present
|
|
104
|
+
required = {'id', 'name', 'tmux_session', 'tmux_window', 'command', 'start_directory', 'start_time'}
|
|
105
|
+
if not all(k in data for k in required):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
# Handle stats separately
|
|
109
|
+
if 'stats' in data and isinstance(data['stats'], dict):
|
|
110
|
+
data['stats'] = SessionStats.from_dict(data['stats'])
|
|
111
|
+
elif 'stats' not in data:
|
|
112
|
+
data['stats'] = SessionStats()
|
|
113
|
+
|
|
114
|
+
# Get valid field names and filter unknown keys
|
|
115
|
+
valid_fields = {f.name for f in fields(cls)}
|
|
116
|
+
filtered = {k: v for k, v in data.items() if k in valid_fields}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
return cls(**filtered)
|
|
120
|
+
except TypeError:
|
|
121
|
+
# Type mismatch or other issue - session is corrupt
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SessionManager:
|
|
126
|
+
"""Manages session state persistence.
|
|
127
|
+
|
|
128
|
+
For testing, pass a custom state_dir (temp directory) and skip_git_detection=True.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, state_dir: Optional[Path] = None, skip_git_detection: bool = False):
|
|
132
|
+
"""Initialize the session manager.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
state_dir: Directory for state files (defaults to ~/.overcode/sessions)
|
|
136
|
+
skip_git_detection: If True, skip git repo/branch detection (for testing)
|
|
137
|
+
"""
|
|
138
|
+
if state_dir is None:
|
|
139
|
+
# Support OVERCODE_STATE_DIR env var for testing
|
|
140
|
+
env_state_dir = os.environ.get("OVERCODE_STATE_DIR")
|
|
141
|
+
if env_state_dir:
|
|
142
|
+
state_dir = Path(env_state_dir) / "sessions"
|
|
143
|
+
else:
|
|
144
|
+
state_dir = Path.home() / ".overcode" / "sessions"
|
|
145
|
+
self.state_dir = Path(state_dir)
|
|
146
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
self.state_file = self.state_dir / "sessions.json"
|
|
148
|
+
self.archive_file = self.state_dir / "archive.json"
|
|
149
|
+
self._skip_git_detection = skip_git_detection
|
|
150
|
+
|
|
151
|
+
def _load_state(self) -> Dict[str, dict]:
|
|
152
|
+
"""Load all sessions from state file with file locking.
|
|
153
|
+
|
|
154
|
+
On JSON corruption, attempts to restore from backup automatically.
|
|
155
|
+
"""
|
|
156
|
+
if not self.state_file.exists():
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
max_retries = 5
|
|
160
|
+
retry_delay = 0.1
|
|
161
|
+
|
|
162
|
+
for attempt in range(max_retries):
|
|
163
|
+
try:
|
|
164
|
+
with open(self.state_file, 'r') as f:
|
|
165
|
+
if HAS_FCNTL:
|
|
166
|
+
# Acquire shared lock for reading
|
|
167
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|
168
|
+
try:
|
|
169
|
+
return json.load(f)
|
|
170
|
+
finally:
|
|
171
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
172
|
+
else:
|
|
173
|
+
# No locking on Windows
|
|
174
|
+
return json.load(f)
|
|
175
|
+
except json.JSONDecodeError as e:
|
|
176
|
+
if attempt < max_retries - 1:
|
|
177
|
+
time.sleep(retry_delay)
|
|
178
|
+
continue
|
|
179
|
+
# JSON corruption detected - try to restore from backup
|
|
180
|
+
print(f"Warning: State file corrupted: {e}")
|
|
181
|
+
if self.restore_from_backup():
|
|
182
|
+
print("Restored sessions from backup file")
|
|
183
|
+
# Try loading the restored file
|
|
184
|
+
try:
|
|
185
|
+
with open(self.state_file, 'r') as f:
|
|
186
|
+
return json.load(f)
|
|
187
|
+
except json.JSONDecodeError:
|
|
188
|
+
print("Warning: Backup file also corrupted, starting fresh")
|
|
189
|
+
return {}
|
|
190
|
+
else:
|
|
191
|
+
print("Warning: No backup available, starting fresh")
|
|
192
|
+
return {}
|
|
193
|
+
except IOError as e:
|
|
194
|
+
if attempt < max_retries - 1:
|
|
195
|
+
time.sleep(retry_delay)
|
|
196
|
+
continue
|
|
197
|
+
print(f"Warning: Could not load state file: {e}")
|
|
198
|
+
return {}
|
|
199
|
+
|
|
200
|
+
return {}
|
|
201
|
+
|
|
202
|
+
def _backup_state(self) -> None:
|
|
203
|
+
"""Create a backup of the current state file before writing."""
|
|
204
|
+
if not self.state_file.exists():
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
backup_file = self.state_file.with_suffix('.json.bak')
|
|
208
|
+
try:
|
|
209
|
+
import shutil
|
|
210
|
+
shutil.copy2(self.state_file, backup_file)
|
|
211
|
+
except (OSError, IOError):
|
|
212
|
+
# Backup is best-effort, don't fail the write
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
def restore_from_backup(self) -> bool:
|
|
216
|
+
"""Restore state from backup file if available.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
True if backup was restored, False otherwise
|
|
220
|
+
"""
|
|
221
|
+
backup_file = self.state_file.with_suffix('.json.bak')
|
|
222
|
+
if not backup_file.exists():
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
import shutil
|
|
227
|
+
shutil.copy2(backup_file, self.state_file)
|
|
228
|
+
return True
|
|
229
|
+
except (OSError, IOError):
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
def _save_state(self, state: Dict[str, dict]):
|
|
233
|
+
"""Save all sessions to state file with file locking and atomic writes"""
|
|
234
|
+
import threading
|
|
235
|
+
max_retries = 5
|
|
236
|
+
retry_delay = 0.1
|
|
237
|
+
|
|
238
|
+
# Create backup before writing
|
|
239
|
+
self._backup_state()
|
|
240
|
+
|
|
241
|
+
for attempt in range(max_retries):
|
|
242
|
+
try:
|
|
243
|
+
if HAS_FCNTL:
|
|
244
|
+
# Use atomic write with exclusive lock
|
|
245
|
+
# Use unique temp file name to avoid race conditions
|
|
246
|
+
temp_suffix = f'.tmp.{os.getpid()}.{threading.get_ident()}'
|
|
247
|
+
temp_file = self.state_file.with_suffix(temp_suffix)
|
|
248
|
+
try:
|
|
249
|
+
with open(temp_file, 'w') as f:
|
|
250
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
251
|
+
try:
|
|
252
|
+
json.dump(state, f, indent=2)
|
|
253
|
+
f.flush()
|
|
254
|
+
os.fsync(f.fileno())
|
|
255
|
+
finally:
|
|
256
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
257
|
+
# Atomic rename
|
|
258
|
+
temp_file.rename(self.state_file)
|
|
259
|
+
finally:
|
|
260
|
+
# Clean up temp file if rename failed
|
|
261
|
+
if temp_file.exists():
|
|
262
|
+
temp_file.unlink()
|
|
263
|
+
else:
|
|
264
|
+
# No locking on Windows, just write
|
|
265
|
+
with open(self.state_file, 'w') as f:
|
|
266
|
+
json.dump(state, f, indent=2)
|
|
267
|
+
return
|
|
268
|
+
except (IOError, OSError) as e:
|
|
269
|
+
if attempt < max_retries - 1:
|
|
270
|
+
time.sleep(retry_delay)
|
|
271
|
+
continue
|
|
272
|
+
raise StateWriteError(f"Failed to save state file after {max_retries} attempts: {e}")
|
|
273
|
+
|
|
274
|
+
def _atomic_update(self, update_fn: Callable[[Dict[str, dict]], Dict[str, dict]]) -> None:
|
|
275
|
+
"""Atomically read, modify, and write state with exclusive lock held throughout.
|
|
276
|
+
|
|
277
|
+
This prevents TOCTOU race conditions by holding the lock during the entire
|
|
278
|
+
read-modify-write cycle.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
update_fn: Function that takes the current state dict and returns the updated state.
|
|
282
|
+
"""
|
|
283
|
+
max_retries = 5
|
|
284
|
+
retry_delay = 0.1
|
|
285
|
+
|
|
286
|
+
for attempt in range(max_retries):
|
|
287
|
+
try:
|
|
288
|
+
if HAS_FCNTL:
|
|
289
|
+
# Use 'a+' to create file if missing, then seek to start
|
|
290
|
+
# This avoids TOCTOU race where file is created outside lock
|
|
291
|
+
with open(self.state_file, 'a+') as f:
|
|
292
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
293
|
+
try:
|
|
294
|
+
f.seek(0)
|
|
295
|
+
content = f.read()
|
|
296
|
+
state = json.loads(content) if content.strip() else {}
|
|
297
|
+
state = update_fn(state)
|
|
298
|
+
f.seek(0)
|
|
299
|
+
f.truncate()
|
|
300
|
+
json.dump(state, f, indent=2)
|
|
301
|
+
f.flush()
|
|
302
|
+
os.fsync(f.fileno())
|
|
303
|
+
finally:
|
|
304
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
305
|
+
else:
|
|
306
|
+
# No locking on Windows - fall back to read/modify/write
|
|
307
|
+
state = self._load_state()
|
|
308
|
+
state = update_fn(state)
|
|
309
|
+
self._save_state(state)
|
|
310
|
+
return
|
|
311
|
+
except (IOError, OSError, json.JSONDecodeError) as e:
|
|
312
|
+
if attempt < max_retries - 1:
|
|
313
|
+
time.sleep(retry_delay)
|
|
314
|
+
continue
|
|
315
|
+
raise StateWriteError(f"Failed to update state file after {max_retries} attempts: {e}")
|
|
316
|
+
|
|
317
|
+
def _detect_git_context(self, directory: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
|
318
|
+
"""Detect git repo and branch from directory"""
|
|
319
|
+
if not directory:
|
|
320
|
+
return None, None
|
|
321
|
+
|
|
322
|
+
# Check directory exists
|
|
323
|
+
if not os.path.isdir(directory):
|
|
324
|
+
return None, None
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
import subprocess
|
|
328
|
+
|
|
329
|
+
# Get repo name
|
|
330
|
+
result = subprocess.run(
|
|
331
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
332
|
+
cwd=directory,
|
|
333
|
+
capture_output=True,
|
|
334
|
+
text=True,
|
|
335
|
+
timeout=2
|
|
336
|
+
)
|
|
337
|
+
repo_path = result.stdout.strip() if result.returncode == 0 else None
|
|
338
|
+
repo_name = Path(repo_path).name if repo_path else None
|
|
339
|
+
|
|
340
|
+
# Get branch
|
|
341
|
+
result = subprocess.run(
|
|
342
|
+
["git", "branch", "--show-current"],
|
|
343
|
+
cwd=directory,
|
|
344
|
+
capture_output=True,
|
|
345
|
+
text=True,
|
|
346
|
+
timeout=2
|
|
347
|
+
)
|
|
348
|
+
branch = result.stdout.strip() if result.returncode == 0 else None
|
|
349
|
+
|
|
350
|
+
return repo_name, branch
|
|
351
|
+
except subprocess.TimeoutExpired:
|
|
352
|
+
print(f"Warning: Git command timed out in {directory}")
|
|
353
|
+
return None, None
|
|
354
|
+
except subprocess.CalledProcessError as e:
|
|
355
|
+
print(f"Warning: Git command failed: {e}")
|
|
356
|
+
return None, None
|
|
357
|
+
except (OSError, IOError) as e:
|
|
358
|
+
print(f"Warning: Could not detect git context: {e}")
|
|
359
|
+
return None, None
|
|
360
|
+
|
|
361
|
+
def refresh_git_context(self, session_id: str) -> bool:
|
|
362
|
+
"""Refresh git repo/branch info for a session.
|
|
363
|
+
|
|
364
|
+
Detects current branch from the session's start_directory and
|
|
365
|
+
updates the session if it has changed.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
True if git context was updated, False otherwise
|
|
369
|
+
"""
|
|
370
|
+
session = self.get_session(session_id)
|
|
371
|
+
if not session or not session.start_directory:
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
repo_name, branch = self._detect_git_context(session.start_directory)
|
|
375
|
+
|
|
376
|
+
# Only update if something changed
|
|
377
|
+
if repo_name != session.repo_name or branch != session.branch:
|
|
378
|
+
self.update_session(
|
|
379
|
+
session_id,
|
|
380
|
+
repo_name=repo_name,
|
|
381
|
+
branch=branch
|
|
382
|
+
)
|
|
383
|
+
return True
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
def create_session(self, name: str, tmux_session: str, tmux_window: int,
|
|
387
|
+
command: List[str], start_directory: Optional[str] = None,
|
|
388
|
+
standing_instructions: str = "",
|
|
389
|
+
permissiveness_mode: str = "normal") -> Session:
|
|
390
|
+
"""Create and register a new session.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
name: Session name
|
|
394
|
+
tmux_session: Name of the tmux session
|
|
395
|
+
tmux_window: Tmux window index
|
|
396
|
+
command: Command used to start the session
|
|
397
|
+
start_directory: Working directory for the session
|
|
398
|
+
standing_instructions: Initial standing instructions (e.g., from config)
|
|
399
|
+
permissiveness_mode: Permission mode (normal, permissive, bypass)
|
|
400
|
+
"""
|
|
401
|
+
if self._skip_git_detection:
|
|
402
|
+
repo_name, branch = None, None
|
|
403
|
+
else:
|
|
404
|
+
repo_name, branch = self._detect_git_context(start_directory)
|
|
405
|
+
|
|
406
|
+
session = Session(
|
|
407
|
+
id=str(uuid.uuid4()),
|
|
408
|
+
name=name,
|
|
409
|
+
tmux_session=tmux_session,
|
|
410
|
+
tmux_window=tmux_window,
|
|
411
|
+
command=command,
|
|
412
|
+
start_directory=start_directory,
|
|
413
|
+
start_time=datetime.now().isoformat(),
|
|
414
|
+
repo_name=repo_name,
|
|
415
|
+
branch=branch,
|
|
416
|
+
standing_instructions=standing_instructions,
|
|
417
|
+
permissiveness_mode=permissiveness_mode
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
state = self._load_state()
|
|
421
|
+
state[session.id] = session.to_dict()
|
|
422
|
+
self._save_state(state)
|
|
423
|
+
|
|
424
|
+
return session
|
|
425
|
+
|
|
426
|
+
def get_session(self, session_id: str) -> Optional[Session]:
|
|
427
|
+
"""Get a session by ID"""
|
|
428
|
+
state = self._load_state()
|
|
429
|
+
if session_id in state:
|
|
430
|
+
return Session.from_dict(state[session_id])
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
def get_session_by_name(self, name: str) -> Optional[Session]:
|
|
434
|
+
"""Get a session by name"""
|
|
435
|
+
state = self._load_state()
|
|
436
|
+
for session_data in state.values():
|
|
437
|
+
if session_data['name'] == name:
|
|
438
|
+
return Session.from_dict(session_data)
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
def list_sessions(self) -> List[Session]:
|
|
442
|
+
"""List all sessions (skips corrupted entries)"""
|
|
443
|
+
state = self._load_state()
|
|
444
|
+
sessions = [Session.from_dict(data) for data in state.values()]
|
|
445
|
+
# Filter out None (corrupted sessions)
|
|
446
|
+
return [s for s in sessions if s is not None]
|
|
447
|
+
|
|
448
|
+
def update_session_status(self, session_id: str, status: str):
|
|
449
|
+
"""Update session status"""
|
|
450
|
+
def do_update(state: Dict[str, dict]) -> Dict[str, dict]:
|
|
451
|
+
if session_id in state:
|
|
452
|
+
state[session_id]['status'] = status
|
|
453
|
+
return state
|
|
454
|
+
self._atomic_update(do_update)
|
|
455
|
+
|
|
456
|
+
def delete_session(self, session_id: str, archive: bool = True):
|
|
457
|
+
"""Delete a session, optionally archiving it first.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
session_id: The session ID to delete
|
|
461
|
+
archive: If True (default), archive the session before removing
|
|
462
|
+
"""
|
|
463
|
+
# Capture session data for archiving before the atomic update
|
|
464
|
+
archived_data = None
|
|
465
|
+
|
|
466
|
+
def do_delete(state: Dict[str, dict]) -> Dict[str, dict]:
|
|
467
|
+
nonlocal archived_data
|
|
468
|
+
if session_id in state:
|
|
469
|
+
if archive:
|
|
470
|
+
# Capture data for archiving
|
|
471
|
+
archived_data = state[session_id].copy()
|
|
472
|
+
archived_data['end_time'] = datetime.now().isoformat()
|
|
473
|
+
archived_data['status'] = 'archived'
|
|
474
|
+
del state[session_id]
|
|
475
|
+
return state
|
|
476
|
+
|
|
477
|
+
self._atomic_update(do_delete)
|
|
478
|
+
|
|
479
|
+
# Archive after the atomic update (separate file, separate lock)
|
|
480
|
+
if archived_data is not None:
|
|
481
|
+
self._archive_session(archived_data)
|
|
482
|
+
|
|
483
|
+
def _load_archive(self) -> Dict[str, dict]:
|
|
484
|
+
"""Load archived sessions."""
|
|
485
|
+
if not self.archive_file.exists():
|
|
486
|
+
return {}
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
with open(self.archive_file, 'r') as f:
|
|
490
|
+
if HAS_FCNTL:
|
|
491
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
|
492
|
+
try:
|
|
493
|
+
return json.load(f)
|
|
494
|
+
finally:
|
|
495
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
496
|
+
else:
|
|
497
|
+
return json.load(f)
|
|
498
|
+
except (json.JSONDecodeError, IOError):
|
|
499
|
+
return {}
|
|
500
|
+
|
|
501
|
+
def _save_archive(self, archive: Dict[str, dict]):
|
|
502
|
+
"""Save archived sessions."""
|
|
503
|
+
import threading
|
|
504
|
+
if HAS_FCNTL:
|
|
505
|
+
temp_suffix = f'.tmp.{os.getpid()}.{threading.get_ident()}'
|
|
506
|
+
temp_file = self.archive_file.with_suffix(temp_suffix)
|
|
507
|
+
try:
|
|
508
|
+
with open(temp_file, 'w') as f:
|
|
509
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
510
|
+
try:
|
|
511
|
+
json.dump(archive, f, indent=2)
|
|
512
|
+
f.flush()
|
|
513
|
+
os.fsync(f.fileno())
|
|
514
|
+
finally:
|
|
515
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
516
|
+
temp_file.rename(self.archive_file)
|
|
517
|
+
finally:
|
|
518
|
+
if temp_file.exists():
|
|
519
|
+
temp_file.unlink()
|
|
520
|
+
else:
|
|
521
|
+
with open(self.archive_file, 'w') as f:
|
|
522
|
+
json.dump(archive, f, indent=2)
|
|
523
|
+
|
|
524
|
+
def _archive_session(self, session_data: dict):
|
|
525
|
+
"""Add a session to the archive."""
|
|
526
|
+
archive = self._load_archive()
|
|
527
|
+
archive[session_data['id']] = session_data
|
|
528
|
+
self._save_archive(archive)
|
|
529
|
+
|
|
530
|
+
def list_archived_sessions(self) -> List[Session]:
|
|
531
|
+
"""List all archived sessions (skips corrupted entries)."""
|
|
532
|
+
archive = self._load_archive()
|
|
533
|
+
sessions = []
|
|
534
|
+
for data in archive.values():
|
|
535
|
+
try:
|
|
536
|
+
# Handle end_time field that's not in Session dataclass
|
|
537
|
+
data_copy = data.copy()
|
|
538
|
+
end_time = data_copy.pop('end_time', None)
|
|
539
|
+
session = Session.from_dict(data_copy)
|
|
540
|
+
if session is None:
|
|
541
|
+
continue
|
|
542
|
+
# Store end_time as attribute for display
|
|
543
|
+
session._end_time = end_time # type: ignore
|
|
544
|
+
sessions.append(session)
|
|
545
|
+
except (KeyError, TypeError):
|
|
546
|
+
continue
|
|
547
|
+
return sessions
|
|
548
|
+
|
|
549
|
+
def get_archived_session(self, session_id: str) -> Optional[Session]:
|
|
550
|
+
"""Get an archived session by ID."""
|
|
551
|
+
archive = self._load_archive()
|
|
552
|
+
if session_id in archive:
|
|
553
|
+
data = archive[session_id].copy()
|
|
554
|
+
end_time = data.pop('end_time', None)
|
|
555
|
+
session = Session.from_dict(data)
|
|
556
|
+
if session is None:
|
|
557
|
+
return None
|
|
558
|
+
session._end_time = end_time # type: ignore
|
|
559
|
+
return session
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
def update_session(self, session_id: str, **kwargs):
|
|
563
|
+
"""Update session fields"""
|
|
564
|
+
def do_update(state: Dict[str, dict]) -> Dict[str, dict]:
|
|
565
|
+
if session_id in state:
|
|
566
|
+
state[session_id].update(kwargs)
|
|
567
|
+
return state
|
|
568
|
+
self._atomic_update(do_update)
|
|
569
|
+
|
|
570
|
+
def update_stats(self, session_id: str, **stats_kwargs):
|
|
571
|
+
"""Update session statistics"""
|
|
572
|
+
def do_update(state: Dict[str, dict]) -> Dict[str, dict]:
|
|
573
|
+
if session_id in state:
|
|
574
|
+
if 'stats' not in state[session_id]:
|
|
575
|
+
state[session_id]['stats'] = SessionStats().to_dict()
|
|
576
|
+
state[session_id]['stats'].update(stats_kwargs)
|
|
577
|
+
return state
|
|
578
|
+
self._atomic_update(do_update)
|
|
579
|
+
|
|
580
|
+
def set_standing_instructions(
|
|
581
|
+
self,
|
|
582
|
+
session_id: str,
|
|
583
|
+
instructions: str,
|
|
584
|
+
preset_name: Optional[str] = None
|
|
585
|
+
):
|
|
586
|
+
"""Set standing instructions for a session (resets complete flag).
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
session_id: The session ID
|
|
590
|
+
instructions: Full instruction text
|
|
591
|
+
preset_name: Preset name if using a library preset, None for custom
|
|
592
|
+
"""
|
|
593
|
+
self.update_session(
|
|
594
|
+
session_id,
|
|
595
|
+
standing_instructions=instructions,
|
|
596
|
+
standing_instructions_preset=preset_name,
|
|
597
|
+
standing_orders_complete=False
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
def set_standing_orders_complete(self, session_id: str, complete: bool = True):
|
|
601
|
+
"""Mark standing orders as complete or incomplete"""
|
|
602
|
+
self.update_session(session_id, standing_orders_complete=complete)
|
|
603
|
+
|
|
604
|
+
def set_permissiveness(self, session_id: str, mode: str):
|
|
605
|
+
"""Set permissiveness mode (normal, permissive, strict)"""
|
|
606
|
+
self.update_session(session_id, permissiveness_mode=mode)
|