loki-mode 6.60.0 → 6.62.0

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 (64) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/app-runner.sh +34 -8
  4. package/autonomy/completion-council.sh +70 -32
  5. package/autonomy/issue-parser.sh +4 -7
  6. package/autonomy/loki +238 -119
  7. package/autonomy/notification-checker.py +49 -23
  8. package/autonomy/run.sh +162 -79
  9. package/autonomy/sandbox.sh +91 -24
  10. package/bin/loki-mode.js +1 -2
  11. package/bin/postinstall.js +10 -4
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/control.py +46 -36
  14. package/dashboard/database.py +21 -4
  15. package/dashboard/server.py +107 -78
  16. package/docs/BUG-AUDIT-v6.61.0.md +957 -0
  17. package/docs/INSTALLATION.md +2 -2
  18. package/events/bus.py +129 -28
  19. package/events/bus.ts +41 -27
  20. package/events/emit.sh +1 -1
  21. package/integrations/openclaw/README.md +139 -0
  22. package/integrations/openclaw/SKILL.md +88 -0
  23. package/integrations/openclaw/bridge/__init__.py +1 -0
  24. package/integrations/openclaw/bridge/__main__.py +88 -0
  25. package/integrations/openclaw/bridge/schema_map.py +180 -0
  26. package/integrations/openclaw/bridge/watcher.py +100 -0
  27. package/integrations/openclaw/scripts/format-progress.sh +80 -0
  28. package/integrations/openclaw/scripts/poll-status.sh +74 -0
  29. package/integrations/vibe-kanban.md +289 -0
  30. package/mcp/__init__.py +1 -1
  31. package/mcp/server.py +96 -73
  32. package/memory/consolidation.py +21 -6
  33. package/memory/engine.py +53 -26
  34. package/memory/layers/index_layer.py +16 -3
  35. package/memory/layers/timeline_layer.py +16 -3
  36. package/memory/retrieval.py +4 -1
  37. package/memory/schemas.py +4 -2
  38. package/memory/storage.py +25 -4
  39. package/memory/token_economics.py +9 -2
  40. package/memory/vector_index.py +2 -2
  41. package/package.json +3 -1
  42. package/providers/cline.sh +5 -4
  43. package/providers/codex.sh +27 -5
  44. package/providers/gemini.sh +59 -23
  45. package/providers/loader.sh +3 -2
  46. package/skills/parallel-workflows.md +9 -7
  47. package/state/__init__.py +10 -0
  48. package/state/index.ts +18 -0
  49. package/state/manager.py +1801 -0
  50. package/state/manager.ts +1774 -0
  51. package/state/sqlite_backend.py +188 -0
  52. package/state/test_manager.py +703 -0
  53. package/state/test_manager.ts +366 -0
  54. package/templates/README.md +19 -4
  55. package/templates/dashboard.md +45 -0
  56. package/templates/data-pipeline.md +45 -0
  57. package/templates/game.md +48 -0
  58. package/templates/microservice.md +49 -0
  59. package/templates/npm-library.md +42 -0
  60. package/templates/rest-api.md +170 -33
  61. package/templates/slack-bot.md +48 -0
  62. package/templates/web-scraper.md +45 -0
  63. package/web-app/server.py +360 -191
  64. package/templates/saas-app.md +0 -42
@@ -0,0 +1,1801 @@
1
+ """
2
+ Centralized State Manager for Loki Mode
3
+
4
+ Provides unified state management with:
5
+ - File-based caching with watchdog for change detection
6
+ - Thread-safe operations with file locking
7
+ - Event bus integration for broadcasting changes
8
+ - Subscription system for reactive updates
9
+ - Version history with rollback capability (SYN-015)
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import fcntl
15
+ import tempfile
16
+ import shutil
17
+ import threading
18
+ import hashlib
19
+ from dataclasses import dataclass, field, asdict
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
23
+ from enum import Enum
24
+ from contextlib import contextmanager
25
+ import uuid
26
+ import glob as glob_module
27
+
28
+ # Try to import watchdog for file monitoring
29
+ try:
30
+ from watchdog.observers import Observer
31
+ from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent
32
+ HAS_WATCHDOG = True
33
+ except ImportError:
34
+ HAS_WATCHDOG = False
35
+ Observer = None
36
+ FileSystemEventHandler = object
37
+
38
+ # Import event bus for broadcasting
39
+ try:
40
+ from events.bus import EventBus, EventType, EventSource, LokiEvent
41
+ HAS_EVENT_BUS = True
42
+ except ImportError:
43
+ HAS_EVENT_BUS = False
44
+ EventBus = None
45
+
46
+
47
+ class ManagedFile(str, Enum):
48
+ """Enumeration of managed state files."""
49
+ ORCHESTRATOR = "state/orchestrator.json"
50
+ AUTONOMY = "autonomy-state.json"
51
+ QUEUE_PENDING = "queue/pending.json"
52
+ QUEUE_IN_PROGRESS = "queue/in-progress.json"
53
+ QUEUE_COMPLETED = "queue/completed.json"
54
+ QUEUE_FAILED = "queue/failed.json"
55
+ QUEUE_CURRENT = "queue/current-task.json"
56
+ MEMORY_INDEX = "memory/index.json"
57
+ MEMORY_TIMELINE = "memory/timeline.json"
58
+ DASHBOARD = "dashboard-state.json"
59
+ AGENTS = "state/agents.json"
60
+ RESOURCES = "state/resources.json"
61
+
62
+
63
+ class ConflictStrategy(str, Enum):
64
+ """Conflict resolution strategies for optimistic updates."""
65
+ LAST_WRITE_WINS = "last_write_wins" # Default: latest write overwrites
66
+ MERGE = "merge" # Merge compatible changes
67
+ REJECT = "reject" # Reject and notify caller
68
+
69
+
70
+ @dataclass
71
+ class VersionVector:
72
+ """Version vector for tracking state versions per source."""
73
+ versions: Dict[str, int] = field(default_factory=dict)
74
+
75
+ def increment(self, source: str) -> None:
76
+ """Increment version for a source."""
77
+ self.versions[source] = self.versions.get(source, 0) + 1
78
+
79
+ def get(self, source: str) -> int:
80
+ """Get version for a source."""
81
+ return self.versions.get(source, 0)
82
+
83
+ def merge(self, other: "VersionVector") -> "VersionVector":
84
+ """Merge two version vectors (take max of each)."""
85
+ merged = VersionVector()
86
+ all_sources = set(self.versions.keys()) | set(other.versions.keys())
87
+ for source in all_sources:
88
+ merged.versions[source] = max(self.get(source), other.get(source))
89
+ return merged
90
+
91
+ def dominates(self, other: "VersionVector") -> bool:
92
+ """Check if this vector dominates (is causally after) another."""
93
+ for source, version in other.versions.items():
94
+ if self.get(source) < version:
95
+ return False
96
+ # Must have at least one greater version
97
+ for source, version in self.versions.items():
98
+ if version > other.get(source):
99
+ return True
100
+ return False
101
+
102
+ def concurrent_with(self, other: "VersionVector") -> bool:
103
+ """Check if two vectors are concurrent (neither dominates).
104
+
105
+ Per causality rules, identical vectors are concurrent (happened
106
+ independently with the same knowledge).
107
+ """
108
+ if self.versions == other.versions:
109
+ return True
110
+ return not self.dominates(other) and not other.dominates(self)
111
+
112
+ def to_dict(self) -> Dict[str, int]:
113
+ """Convert to dictionary."""
114
+ return dict(self.versions)
115
+
116
+ @classmethod
117
+ def from_dict(cls, data: Dict[str, int]) -> "VersionVector":
118
+ """Create from dictionary."""
119
+ vv = cls()
120
+ vv.versions = dict(data)
121
+ return vv
122
+
123
+
124
+ @dataclass
125
+ class PendingUpdate:
126
+ """Represents a pending optimistic update."""
127
+ id: str
128
+ key: str
129
+ value: Any
130
+ source: str
131
+ timestamp: str
132
+ version_vector: VersionVector
133
+ status: str = "pending" # pending, committed, rejected
134
+
135
+ def to_dict(self) -> Dict[str, Any]:
136
+ """Convert to dictionary."""
137
+ return {
138
+ "id": self.id,
139
+ "key": self.key,
140
+ "value": self.value,
141
+ "source": self.source,
142
+ "timestamp": self.timestamp,
143
+ "version_vector": self.version_vector.to_dict(),
144
+ "status": self.status
145
+ }
146
+
147
+
148
+ @dataclass
149
+ class ConflictInfo:
150
+ """Information about a detected conflict."""
151
+ key: str
152
+ local_value: Any
153
+ remote_value: Any
154
+ local_source: str
155
+ remote_source: str
156
+ local_version: VersionVector
157
+ remote_version: VersionVector
158
+ resolution: Optional[str] = None
159
+ resolved_value: Optional[Any] = None
160
+
161
+ def to_dict(self) -> Dict[str, Any]:
162
+ """Convert to dictionary."""
163
+ return {
164
+ "key": self.key,
165
+ "local_value": self.local_value,
166
+ "remote_value": self.remote_value,
167
+ "local_source": self.local_source,
168
+ "remote_source": self.remote_source,
169
+ "local_version": self.local_version.to_dict(),
170
+ "remote_version": self.remote_version.to_dict(),
171
+ "resolution": self.resolution,
172
+ "resolved_value": self.resolved_value
173
+ }
174
+
175
+
176
+ @dataclass
177
+ class StateChange:
178
+ """Represents a state change event."""
179
+ file_path: str
180
+ old_value: Optional[Dict[str, Any]]
181
+ new_value: Dict[str, Any]
182
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
183
+ change_type: str = "update" # create, update, delete
184
+ source: str = "state-manager"
185
+
186
+ def to_dict(self) -> Dict[str, Any]:
187
+ """Convert to dictionary."""
188
+ return {
189
+ "file_path": self.file_path,
190
+ "old_value": self.old_value,
191
+ "new_value": self.new_value,
192
+ "timestamp": self.timestamp,
193
+ "change_type": self.change_type,
194
+ "source": self.source
195
+ }
196
+
197
+ def get_diff(self) -> Dict[str, Any]:
198
+ """Get the diff between old and new values."""
199
+ if self.old_value is None:
200
+ return {"added": self.new_value, "removed": {}, "changed": {}}
201
+
202
+ diff = {"added": {}, "removed": {}, "changed": {}}
203
+
204
+ old_keys = set(self.old_value.keys()) if self.old_value else set()
205
+ new_keys = set(self.new_value.keys()) if self.new_value else set()
206
+
207
+ # Added keys
208
+ for key in new_keys - old_keys:
209
+ diff["added"][key] = self.new_value[key]
210
+
211
+ # Removed keys
212
+ for key in old_keys - new_keys:
213
+ diff["removed"][key] = self.old_value[key]
214
+
215
+ # Changed keys
216
+ for key in old_keys & new_keys:
217
+ if self.old_value[key] != self.new_value[key]:
218
+ diff["changed"][key] = {
219
+ "old": self.old_value[key],
220
+ "new": self.new_value[key]
221
+ }
222
+
223
+ return diff
224
+
225
+
226
+ # Default version retention limit
227
+ DEFAULT_VERSION_RETENTION = 10
228
+
229
+
230
+ @dataclass
231
+ class StateVersion:
232
+ """Represents a historical version of state."""
233
+ version: int
234
+ timestamp: str
235
+ data: Dict[str, Any]
236
+ source: str
237
+ change_type: str
238
+
239
+ def to_dict(self) -> Dict[str, Any]:
240
+ """Convert to dictionary."""
241
+ return {
242
+ "version": self.version,
243
+ "timestamp": self.timestamp,
244
+ "data": self.data,
245
+ "source": self.source,
246
+ "change_type": self.change_type
247
+ }
248
+
249
+ @classmethod
250
+ def from_dict(cls, data: Dict[str, Any]) -> "StateVersion":
251
+ """Create from dictionary."""
252
+ return cls(
253
+ version=data["version"],
254
+ timestamp=data["timestamp"],
255
+ data=data["data"],
256
+ source=data.get("source", "unknown"),
257
+ change_type=data.get("change_type", "update")
258
+ )
259
+
260
+
261
+ @dataclass
262
+ class VersionInfo:
263
+ """Summary info for a version (without full data)."""
264
+ version: int
265
+ timestamp: str
266
+ source: str
267
+ change_type: str
268
+ data_hash: str
269
+
270
+ def to_dict(self) -> Dict[str, Any]:
271
+ """Convert to dictionary."""
272
+ return {
273
+ "version": self.version,
274
+ "timestamp": self.timestamp,
275
+ "source": self.source,
276
+ "change_type": self.change_type,
277
+ "data_hash": self.data_hash
278
+ }
279
+
280
+
281
+ class StateFileHandler(FileSystemEventHandler if HAS_WATCHDOG else object):
282
+ """Watchdog handler for state file changes."""
283
+
284
+ def __init__(self, manager: "StateManager"):
285
+ self.manager = manager
286
+ if HAS_WATCHDOG:
287
+ super().__init__()
288
+
289
+ def on_modified(self, event):
290
+ """Handle file modification."""
291
+ if hasattr(event, 'is_directory') and event.is_directory:
292
+ return
293
+ if hasattr(event, 'src_path'):
294
+ self.manager._on_file_changed(event.src_path)
295
+
296
+ def on_created(self, event):
297
+ """Handle file creation."""
298
+ if hasattr(event, 'is_directory') and event.is_directory:
299
+ return
300
+ if hasattr(event, 'src_path'):
301
+ self.manager._on_file_changed(event.src_path)
302
+
303
+
304
+ # Type alias for subscription callbacks
305
+ StateCallback = Callable[[StateChange], None]
306
+
307
+
308
+ # -----------------------------------------------------------------------------
309
+ # Subscription Filters (SYN-016)
310
+ # -----------------------------------------------------------------------------
311
+
312
+ class SubscriptionFilter:
313
+ """
314
+ Filter for selective state change subscriptions.
315
+
316
+ Allows subscribing to specific files and/or change types.
317
+ """
318
+
319
+ def __init__(
320
+ self,
321
+ files: Optional[List[Union[str, ManagedFile]]] = None,
322
+ change_types: Optional[List[str]] = None
323
+ ):
324
+ """
325
+ Initialize subscription filter.
326
+
327
+ Args:
328
+ files: List of files to subscribe to (None = all files)
329
+ change_types: List of change types to filter ("create", "update", "delete")
330
+ """
331
+ self.files = files
332
+ self.change_types = change_types
333
+
334
+ def matches(self, change: StateChange, loki_dir: Path) -> bool:
335
+ """Check if a change matches this filter."""
336
+ # Check file filter
337
+ if self.files:
338
+ filter_paths = set()
339
+ for f in self.files:
340
+ if isinstance(f, ManagedFile):
341
+ filter_paths.add(f.value)
342
+ else:
343
+ filter_paths.add(f)
344
+
345
+ if change.file_path not in filter_paths:
346
+ return False
347
+
348
+ # Check change type filter
349
+ if self.change_types:
350
+ if change.change_type not in self.change_types:
351
+ return False
352
+
353
+ return True
354
+
355
+
356
+ # -----------------------------------------------------------------------------
357
+ # Notification Channels (SYN-016)
358
+ # -----------------------------------------------------------------------------
359
+
360
+ class NotificationChannel:
361
+ """Abstract base for notification channels."""
362
+
363
+ def notify(self, change: StateChange) -> None:
364
+ """Send notification for a state change."""
365
+ raise NotImplementedError
366
+
367
+ def close(self) -> None:
368
+ """Close the notification channel."""
369
+ pass
370
+
371
+
372
+ class FileNotificationChannel(NotificationChannel):
373
+ """
374
+ File-based notification channel for CLI/scripts.
375
+
376
+ Writes change notifications to a file that can be monitored
377
+ by external tools using tail -f or similar.
378
+ """
379
+
380
+ def __init__(self, notification_file: Path):
381
+ """Initialize file notification channel."""
382
+ self.notification_file = notification_file
383
+ self.notification_file.parent.mkdir(parents=True, exist_ok=True)
384
+
385
+ def notify(self, change: StateChange) -> None:
386
+ """Write notification to file as JSONL (one JSON per line)."""
387
+ try:
388
+ notification = {
389
+ "timestamp": change.timestamp,
390
+ "file_path": change.file_path,
391
+ "change_type": change.change_type,
392
+ "source": change.source,
393
+ "diff": change.get_diff()
394
+ }
395
+ with open(self.notification_file, "a") as f:
396
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
397
+ f.write(json.dumps(notification) + "\n")
398
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
399
+ except IOError:
400
+ pass # File notification errors shouldn't break state management
401
+
402
+
403
+ class InMemoryNotificationChannel(NotificationChannel):
404
+ """
405
+ In-memory notification channel for testing and embedding.
406
+
407
+ Stores notifications in a list for later inspection.
408
+ """
409
+
410
+ def __init__(self, max_size: int = 1000):
411
+ """Initialize in-memory notification channel."""
412
+ self.notifications: List[Dict[str, Any]] = []
413
+ self.max_size = max_size
414
+ self._lock = threading.Lock()
415
+
416
+ def notify(self, change: StateChange) -> None:
417
+ """Store notification in memory."""
418
+ with self._lock:
419
+ notification = {
420
+ "timestamp": change.timestamp,
421
+ "file_path": change.file_path,
422
+ "change_type": change.change_type,
423
+ "source": change.source,
424
+ "old_value": change.old_value,
425
+ "new_value": change.new_value,
426
+ "diff": change.get_diff()
427
+ }
428
+ self.notifications.append(notification)
429
+ # Keep only recent notifications
430
+ if len(self.notifications) > self.max_size:
431
+ self.notifications = self.notifications[-self.max_size:]
432
+
433
+ def get_notifications(self) -> List[Dict[str, Any]]:
434
+ """Get all stored notifications."""
435
+ with self._lock:
436
+ return list(self.notifications)
437
+
438
+ def clear(self) -> None:
439
+ """Clear all stored notifications."""
440
+ with self._lock:
441
+ self.notifications.clear()
442
+
443
+
444
+ class StateManager:
445
+ """
446
+ Centralized state manager for Loki Mode.
447
+
448
+ Manages state files with:
449
+ - In-memory caching for fast reads
450
+ - File locking for thread-safe writes
451
+ - File watching for external change detection
452
+ - Event bus integration for broadcasting
453
+ - Subscription system for reactive updates
454
+
455
+ Usage:
456
+ manager = StateManager()
457
+
458
+ # Get state
459
+ state = manager.get_state(ManagedFile.ORCHESTRATOR)
460
+
461
+ # Set state
462
+ manager.set_state(ManagedFile.ORCHESTRATOR, {"phase": "planning"})
463
+
464
+ # Subscribe to changes
465
+ def on_change(change: StateChange):
466
+ print(f"Changed: {change.file_path}")
467
+
468
+ unsubscribe = manager.subscribe(on_change)
469
+
470
+ # Cleanup
471
+ manager.stop()
472
+ """
473
+
474
+ def __init__(
475
+ self,
476
+ loki_dir: Optional[Union[str, Path]] = None,
477
+ enable_watch: bool = True,
478
+ enable_events: bool = True,
479
+ enable_versioning: bool = True,
480
+ version_retention: int = DEFAULT_VERSION_RETENTION
481
+ ):
482
+ """
483
+ Initialize the state manager.
484
+
485
+ Args:
486
+ loki_dir: Path to .loki directory. Defaults to ./.loki
487
+ enable_watch: Enable file watching for external changes
488
+ enable_events: Enable event bus integration
489
+ enable_versioning: Enable version history for rollback (SYN-015)
490
+ version_retention: Number of versions to retain per file (default: 10)
491
+ """
492
+ self.loki_dir = Path(loki_dir) if loki_dir else Path(".loki")
493
+ self.enable_watch = enable_watch and HAS_WATCHDOG
494
+ self.enable_events = enable_events and HAS_EVENT_BUS
495
+ self.enable_versioning = enable_versioning
496
+ self.version_retention = version_retention
497
+
498
+ # In-memory cache: file_path -> (data, hash, mtime)
499
+ self._cache: Dict[str, tuple] = {}
500
+ self._cache_lock = threading.RLock()
501
+
502
+ # Subscribers: set of callbacks
503
+ self._subscribers: Set[StateCallback] = set()
504
+ self._subscriber_lock = threading.Lock()
505
+
506
+ # File watcher
507
+ self._observer: Optional[Observer] = None
508
+ self._handler: Optional[StateFileHandler] = None
509
+
510
+ # Event bus
511
+ self._event_bus: Optional[EventBus] = None
512
+
513
+ # Optimistic updates tracking
514
+ self._pending_updates: Dict[str, List[PendingUpdate]] = {} # file_path -> list of pending updates
515
+ self._version_vectors: Dict[str, VersionVector] = {} # file_path -> version vector
516
+ self._conflict_strategy: ConflictStrategy = ConflictStrategy.LAST_WRITE_WINS
517
+
518
+ # Version counters for state versioning (SYN-015)
519
+ self._version_counters: Dict[str, int] = {} # file_key -> current version number
520
+
521
+ # Notification channels (SYN-016)
522
+ self._notification_channels: List[NotificationChannel] = []
523
+ self._notification_channels_lock = threading.Lock()
524
+
525
+ # Filtered subscribers (SYN-016): list of (callback, filter)
526
+ self._filtered_subscribers: List[Tuple[StateCallback, SubscriptionFilter]] = []
527
+
528
+ # Ensure directories exist
529
+ self._ensure_directories()
530
+
531
+ # Start file watching
532
+ if self.enable_watch:
533
+ self._start_watching()
534
+
535
+ # Initialize event bus
536
+ if self.enable_events:
537
+ self._init_event_bus()
538
+
539
+ def _ensure_directories(self) -> None:
540
+ """Ensure all required directories exist."""
541
+ directories = [
542
+ self.loki_dir,
543
+ self.loki_dir / "state",
544
+ self.loki_dir / "state" / "history", # Version history (SYN-015)
545
+ self.loki_dir / "queue",
546
+ self.loki_dir / "memory",
547
+ self.loki_dir / "events",
548
+ ]
549
+ for directory in directories:
550
+ directory.mkdir(parents=True, exist_ok=True)
551
+
552
+ def _start_watching(self) -> None:
553
+ """Start file system watcher."""
554
+ if not HAS_WATCHDOG:
555
+ return
556
+
557
+ self._handler = StateFileHandler(self)
558
+ self._observer = Observer()
559
+ self._observer.schedule(self._handler, str(self.loki_dir), recursive=True)
560
+ self._observer.start()
561
+
562
+ def _init_event_bus(self) -> None:
563
+ """Initialize event bus connection."""
564
+ if not HAS_EVENT_BUS:
565
+ return
566
+
567
+ self._event_bus = EventBus(self.loki_dir)
568
+
569
+ def stop(self) -> None:
570
+ """Stop the state manager and cleanup resources."""
571
+ if self._observer:
572
+ self._observer.stop()
573
+ self._observer.join(timeout=2.0)
574
+ self._observer = None
575
+
576
+ self._cache.clear()
577
+ self._subscribers.clear()
578
+ self._filtered_subscribers.clear()
579
+
580
+ # Close all notification channels
581
+ with self._notification_channels_lock:
582
+ for channel in self._notification_channels:
583
+ try:
584
+ channel.close()
585
+ except Exception:
586
+ pass
587
+ self._notification_channels.clear()
588
+
589
+ # -------------------------------------------------------------------------
590
+ # File Locking
591
+ # -------------------------------------------------------------------------
592
+
593
+ @contextmanager
594
+ def _file_lock(self, path: Path, exclusive: bool = True):
595
+ """
596
+ Context manager for file locking.
597
+
598
+ Args:
599
+ path: Path to the file to lock
600
+ exclusive: If True, acquire exclusive lock. Otherwise shared lock.
601
+
602
+ Yields:
603
+ None (lock is held)
604
+ """
605
+ lock_path = path.with_suffix(path.suffix + ".lock")
606
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
607
+
608
+ lock_file = None
609
+ try:
610
+ lock_file = open(lock_path, "w")
611
+ lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
612
+ fcntl.flock(lock_file.fileno(), lock_type)
613
+ yield
614
+ finally:
615
+ if lock_file is not None:
616
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
617
+ lock_file.close()
618
+
619
+ # -------------------------------------------------------------------------
620
+ # File I/O
621
+ # -------------------------------------------------------------------------
622
+
623
+ def _resolve_path(self, file_ref: Union[str, ManagedFile]) -> Path:
624
+ """Resolve a file reference to an absolute path."""
625
+ if isinstance(file_ref, ManagedFile):
626
+ rel_path = file_ref.value
627
+ else:
628
+ rel_path = file_ref
629
+
630
+ return self.loki_dir / rel_path
631
+
632
+ def _compute_hash(self, data: Dict[str, Any]) -> str:
633
+ """Compute a hash of the data for change detection."""
634
+ content = json.dumps(data, sort_keys=True, default=str)
635
+ return hashlib.md5(content.encode()).hexdigest()
636
+
637
+ def _read_file(self, path: Path) -> Optional[Dict[str, Any]]:
638
+ """Read JSON file with shared lock."""
639
+ if not path.exists():
640
+ return None
641
+
642
+ with self._file_lock(path, exclusive=False):
643
+ with open(path, "r") as f:
644
+ try:
645
+ return json.load(f)
646
+ except json.JSONDecodeError:
647
+ # Handle corrupted or empty JSON files
648
+ return None
649
+
650
+ def _write_file(self, path: Path, data: Dict[str, Any]) -> None:
651
+ """Write JSON file atomically with exclusive lock."""
652
+ path.parent.mkdir(parents=True, exist_ok=True)
653
+
654
+ with self._file_lock(path, exclusive=True):
655
+ # Write to temp file first
656
+ fd, temp_path = tempfile.mkstemp(
657
+ dir=path.parent,
658
+ prefix=".tmp_",
659
+ suffix=".json"
660
+ )
661
+ try:
662
+ with os.fdopen(fd, "w") as f:
663
+ json.dump(data, f, indent=2, default=str)
664
+ # Atomic rename
665
+ shutil.move(temp_path, path)
666
+ except Exception:
667
+ if os.path.exists(temp_path):
668
+ os.unlink(temp_path)
669
+ raise
670
+
671
+ # -------------------------------------------------------------------------
672
+ # Cache Management
673
+ # -------------------------------------------------------------------------
674
+
675
+ def _get_from_cache(self, path: Path) -> Optional[Dict[str, Any]]:
676
+ """Get data from cache if valid."""
677
+ with self._cache_lock:
678
+ if str(path) not in self._cache:
679
+ return None
680
+
681
+ data, cached_hash, cached_mtime = self._cache[str(path)]
682
+
683
+ # Check if file still exists and mtime matches
684
+ if path.exists():
685
+ current_mtime = path.stat().st_mtime
686
+ if current_mtime == cached_mtime:
687
+ return data
688
+
689
+ return None
690
+
691
+ def _put_in_cache(self, path: Path, data: Dict[str, Any]) -> None:
692
+ """Put data in cache."""
693
+ with self._cache_lock:
694
+ data_hash = self._compute_hash(data)
695
+ mtime = path.stat().st_mtime if path.exists() else 0
696
+ self._cache[str(path)] = (data, data_hash, mtime)
697
+
698
+ def _invalidate_cache(self, path: Path) -> None:
699
+ """Invalidate cache entry."""
700
+ with self._cache_lock:
701
+ self._cache.pop(str(path), None)
702
+
703
+ # -------------------------------------------------------------------------
704
+ # State Operations
705
+ # -------------------------------------------------------------------------
706
+
707
+ def get_state(
708
+ self,
709
+ file_ref: Union[str, ManagedFile],
710
+ default: Optional[Dict[str, Any]] = None
711
+ ) -> Optional[Dict[str, Any]]:
712
+ """
713
+ Get state from a managed file.
714
+
715
+ Args:
716
+ file_ref: File reference (ManagedFile enum or relative path)
717
+ default: Default value if file doesn't exist
718
+
719
+ Returns:
720
+ State dictionary or default value
721
+ """
722
+ path = self._resolve_path(file_ref)
723
+
724
+ # Try cache first
725
+ cached = self._get_from_cache(path)
726
+ if cached is not None:
727
+ return cached
728
+
729
+ # Read from file
730
+ data = self._read_file(path)
731
+ if data is None:
732
+ return default
733
+
734
+ # Update cache
735
+ self._put_in_cache(path, data)
736
+
737
+ return data
738
+
739
+ def set_state(
740
+ self,
741
+ file_ref: Union[str, ManagedFile],
742
+ data: Dict[str, Any],
743
+ source: str = "state-manager",
744
+ save_version: bool = True
745
+ ) -> StateChange:
746
+ """
747
+ Set state in a managed file.
748
+
749
+ Args:
750
+ file_ref: File reference (ManagedFile enum or relative path)
751
+ data: State data to write
752
+ source: Source of the change (for tracking)
753
+ save_version: Whether to save a version history entry (SYN-015)
754
+
755
+ Returns:
756
+ StateChange object describing the change
757
+ """
758
+ path = self._resolve_path(file_ref)
759
+
760
+ # Get old value for change tracking
761
+ old_value = self.get_state(file_ref)
762
+
763
+ # Determine change type
764
+ change_type = "create" if old_value is None else "update"
765
+
766
+ # Save version before writing new data (SYN-015)
767
+ if self.enable_versioning and save_version and old_value is not None:
768
+ self._save_version(file_ref, old_value, source, change_type)
769
+
770
+ # Write to file
771
+ self._write_file(path, data)
772
+
773
+ # Update cache
774
+ self._put_in_cache(path, data)
775
+
776
+ # Create change object
777
+ change = StateChange(
778
+ file_path=str(path.relative_to(self.loki_dir)),
779
+ old_value=old_value,
780
+ new_value=data,
781
+ change_type=change_type,
782
+ source=source
783
+ )
784
+
785
+ # Broadcast change
786
+ self._broadcast(change)
787
+
788
+ return change
789
+
790
+ def update_state(
791
+ self,
792
+ file_ref: Union[str, ManagedFile],
793
+ updates: Dict[str, Any],
794
+ source: str = "state-manager"
795
+ ) -> StateChange:
796
+ """
797
+ Merge updates into existing state.
798
+
799
+ Args:
800
+ file_ref: File reference
801
+ updates: Dictionary of updates to merge
802
+ source: Source of the change
803
+
804
+ Returns:
805
+ StateChange object
806
+ """
807
+ current = self.get_state(file_ref, default={})
808
+ merged = {**current, **updates}
809
+ return self.set_state(file_ref, merged, source)
810
+
811
+ def delete_state(
812
+ self,
813
+ file_ref: Union[str, ManagedFile],
814
+ source: str = "state-manager"
815
+ ) -> Optional[StateChange]:
816
+ """
817
+ Delete a managed state file.
818
+
819
+ Args:
820
+ file_ref: File reference
821
+ source: Source of the change
822
+
823
+ Returns:
824
+ StateChange object or None if file didn't exist
825
+ """
826
+ path = self._resolve_path(file_ref)
827
+
828
+ if not path.exists():
829
+ return None
830
+
831
+ old_value = self.get_state(file_ref)
832
+
833
+ with self._file_lock(path, exclusive=True):
834
+ path.unlink()
835
+
836
+ # Invalidate cache
837
+ self._invalidate_cache(path)
838
+
839
+ # Create change object
840
+ change = StateChange(
841
+ file_path=str(path.relative_to(self.loki_dir)),
842
+ old_value=old_value,
843
+ new_value={},
844
+ change_type="delete",
845
+ source=source
846
+ )
847
+
848
+ # Broadcast change
849
+ self._broadcast(change)
850
+
851
+ return change
852
+
853
+ # -------------------------------------------------------------------------
854
+ # Subscriptions
855
+ # -------------------------------------------------------------------------
856
+
857
+ def subscribe(
858
+ self,
859
+ callback: StateCallback,
860
+ file_filter: Optional[List[Union[str, ManagedFile]]] = None,
861
+ change_types: Optional[List[str]] = None
862
+ ) -> Callable[[], None]:
863
+ """
864
+ Subscribe to state changes with optional filtering.
865
+
866
+ Args:
867
+ callback: Function to call on state changes
868
+ file_filter: Optional list of files to filter (None = all files)
869
+ change_types: Optional list of change types to filter ("create", "update", "delete")
870
+
871
+ Returns:
872
+ Unsubscribe function
873
+ """
874
+ # Create filter if any filtering is specified
875
+ if file_filter or change_types:
876
+ sub_filter = SubscriptionFilter(files=file_filter, change_types=change_types)
877
+ with self._subscriber_lock:
878
+ self._filtered_subscribers.append((callback, sub_filter))
879
+
880
+ def unsubscribe():
881
+ with self._subscriber_lock:
882
+ self._filtered_subscribers = [
883
+ (cb, flt) for cb, flt in self._filtered_subscribers
884
+ if cb is not callback
885
+ ]
886
+
887
+ return unsubscribe
888
+ else:
889
+ # No filter, use simple subscriber set
890
+ with self._subscriber_lock:
891
+ self._subscribers.add(callback)
892
+
893
+ def unsubscribe():
894
+ with self._subscriber_lock:
895
+ self._subscribers.discard(callback)
896
+
897
+ return unsubscribe
898
+
899
+ def subscribe_filtered(
900
+ self,
901
+ callback: StateCallback,
902
+ filter_obj: SubscriptionFilter
903
+ ) -> Callable[[], None]:
904
+ """
905
+ Subscribe to state changes with a SubscriptionFilter object.
906
+
907
+ Args:
908
+ callback: Function to call on state changes
909
+ filter_obj: SubscriptionFilter specifying files and/or change types
910
+
911
+ Returns:
912
+ Unsubscribe function
913
+ """
914
+ with self._subscriber_lock:
915
+ self._filtered_subscribers.append((callback, filter_obj))
916
+
917
+ def unsubscribe():
918
+ with self._subscriber_lock:
919
+ self._filtered_subscribers = [
920
+ (cb, flt) for cb, flt in self._filtered_subscribers
921
+ if cb is not callback
922
+ ]
923
+
924
+ return unsubscribe
925
+
926
+ def add_notification_channel(self, channel: NotificationChannel) -> Callable[[], None]:
927
+ """
928
+ Add a notification channel for state changes.
929
+
930
+ Args:
931
+ channel: NotificationChannel to add
932
+
933
+ Returns:
934
+ Function to remove the channel
935
+ """
936
+ with self._notification_channels_lock:
937
+ self._notification_channels.append(channel)
938
+
939
+ def remove_channel():
940
+ with self._notification_channels_lock:
941
+ if channel in self._notification_channels:
942
+ self._notification_channels.remove(channel)
943
+ channel.close()
944
+
945
+ return remove_channel
946
+
947
+ def _notify_subscribers(self, change: StateChange) -> None:
948
+ """Notify all internal subscribers (callback-based)."""
949
+ # Notify simple subscribers (no filter)
950
+ with self._subscriber_lock:
951
+ simple_subscribers = list(self._subscribers)
952
+ filtered_subscribers = list(self._filtered_subscribers)
953
+
954
+ for callback in simple_subscribers:
955
+ try:
956
+ callback(change)
957
+ except Exception:
958
+ pass # Don't let one callback break others
959
+
960
+ # Notify filtered subscribers
961
+ for callback, sub_filter in filtered_subscribers:
962
+ try:
963
+ if sub_filter.matches(change, self.loki_dir):
964
+ callback(change)
965
+ except Exception:
966
+ pass # Don't let one callback break others
967
+
968
+ def _emit_state_event(self, change: StateChange) -> None:
969
+ """Emit state change event to the event bus."""
970
+ if not self._event_bus or not self.enable_events:
971
+ return
972
+
973
+ try:
974
+ self._event_bus.emit_simple(
975
+ event_type=EventType.STATE,
976
+ source=EventSource.RUNNER,
977
+ action="state_changed",
978
+ file_path=change.file_path,
979
+ change_type=change.change_type,
980
+ source_component=change.source,
981
+ timestamp=change.timestamp,
982
+ diff=change.get_diff()
983
+ )
984
+ except Exception:
985
+ pass # Event bus errors shouldn't break state management
986
+
987
+ def _notify_channels(self, change: StateChange) -> None:
988
+ """Notify all notification channels (file-based, WebSocket, etc.)."""
989
+ with self._notification_channels_lock:
990
+ channels = list(self._notification_channels)
991
+
992
+ for channel in channels:
993
+ try:
994
+ channel.notify(change)
995
+ except Exception:
996
+ pass # Channel errors shouldn't break state management
997
+
998
+ def _broadcast(self, change: StateChange) -> None:
999
+ """
1000
+ Broadcast a state change to all subscribers and channels.
1001
+
1002
+ This is the main notification method that:
1003
+ 1. Notifies internal callback subscribers
1004
+ 2. Emits events to the event bus
1005
+ 3. Sends notifications to all registered channels (file, WebSocket, etc.)
1006
+ """
1007
+ # 1. Notify internal subscribers
1008
+ self._notify_subscribers(change)
1009
+
1010
+ # 2. Emit to event bus
1011
+ self._emit_state_event(change)
1012
+
1013
+ # 3. Notify notification channels
1014
+ self._notify_channels(change)
1015
+
1016
+ # -------------------------------------------------------------------------
1017
+ # File Watching
1018
+ # -------------------------------------------------------------------------
1019
+
1020
+ def _on_file_changed(self, file_path: str) -> None:
1021
+ """Handle file change detected by watchdog."""
1022
+ path = Path(file_path)
1023
+
1024
+ # Only handle JSON files
1025
+ if not path.suffix == ".json":
1026
+ return
1027
+
1028
+ # Ignore lock files and temp files
1029
+ if ".lock" in path.name or path.name.startswith(".tmp_"):
1030
+ return
1031
+
1032
+ # Get old value from cache BEFORE invalidating (fix race condition)
1033
+ with self._cache_lock:
1034
+ old_entry = self._cache.get(str(path))
1035
+ old_value = old_entry[0] if old_entry else None
1036
+
1037
+ # Invalidate cache
1038
+ self._invalidate_cache(path)
1039
+
1040
+ # Read new value
1041
+ try:
1042
+ new_value = self._read_file(path)
1043
+ except Exception:
1044
+ return
1045
+
1046
+ if new_value is None:
1047
+ return
1048
+
1049
+ # Update cache
1050
+ self._put_in_cache(path, new_value)
1051
+
1052
+ # Create and broadcast change
1053
+ try:
1054
+ rel_path = path.relative_to(self.loki_dir)
1055
+ except ValueError:
1056
+ rel_path = path
1057
+
1058
+ change = StateChange(
1059
+ file_path=str(rel_path),
1060
+ old_value=old_value,
1061
+ new_value=new_value,
1062
+ change_type="update",
1063
+ source="external"
1064
+ )
1065
+
1066
+ self._broadcast(change)
1067
+
1068
+ # -------------------------------------------------------------------------
1069
+ # Convenience Methods
1070
+ # -------------------------------------------------------------------------
1071
+
1072
+ def get_orchestrator_state(self) -> Optional[Dict[str, Any]]:
1073
+ """Get orchestrator state."""
1074
+ return self.get_state(ManagedFile.ORCHESTRATOR)
1075
+
1076
+ def get_autonomy_state(self) -> Optional[Dict[str, Any]]:
1077
+ """Get autonomy state."""
1078
+ return self.get_state(ManagedFile.AUTONOMY)
1079
+
1080
+ def get_queue_state(self, queue_type: str = "pending") -> Optional[Dict[str, Any]]:
1081
+ """Get queue state by type."""
1082
+ queue_map = {
1083
+ "pending": ManagedFile.QUEUE_PENDING,
1084
+ "in-progress": ManagedFile.QUEUE_IN_PROGRESS,
1085
+ "completed": ManagedFile.QUEUE_COMPLETED,
1086
+ "failed": ManagedFile.QUEUE_FAILED,
1087
+ "current": ManagedFile.QUEUE_CURRENT,
1088
+ }
1089
+ file_ref = queue_map.get(queue_type, ManagedFile.QUEUE_PENDING)
1090
+ return self.get_state(file_ref)
1091
+
1092
+ def get_memory_index(self) -> Optional[Dict[str, Any]]:
1093
+ """Get memory index."""
1094
+ return self.get_state(ManagedFile.MEMORY_INDEX)
1095
+
1096
+ def set_orchestrator_state(
1097
+ self,
1098
+ state: Dict[str, Any],
1099
+ source: str = "orchestrator"
1100
+ ) -> StateChange:
1101
+ """Set orchestrator state."""
1102
+ return self.set_state(ManagedFile.ORCHESTRATOR, state, source)
1103
+
1104
+ def set_autonomy_state(
1105
+ self,
1106
+ state: Dict[str, Any],
1107
+ source: str = "autonomy"
1108
+ ) -> StateChange:
1109
+ """Set autonomy state."""
1110
+ return self.set_state(ManagedFile.AUTONOMY, state, source)
1111
+
1112
+ def update_orchestrator_phase(
1113
+ self,
1114
+ phase: str,
1115
+ source: str = "orchestrator"
1116
+ ) -> StateChange:
1117
+ """Update orchestrator phase."""
1118
+ return self.update_state(
1119
+ ManagedFile.ORCHESTRATOR,
1120
+ {"currentPhase": phase, "lastUpdated": datetime.now(timezone.utc).isoformat()},
1121
+ source
1122
+ )
1123
+
1124
+ def update_autonomy_status(
1125
+ self,
1126
+ status: str,
1127
+ source: str = "autonomy"
1128
+ ) -> StateChange:
1129
+ """Update autonomy status."""
1130
+ return self.update_state(
1131
+ ManagedFile.AUTONOMY,
1132
+ {"status": status, "lastRun": datetime.now(timezone.utc).isoformat()},
1133
+ source
1134
+ )
1135
+
1136
+ # -------------------------------------------------------------------------
1137
+ # Batch Operations
1138
+ # -------------------------------------------------------------------------
1139
+
1140
+ def get_all_states(self) -> Dict[str, Dict[str, Any]]:
1141
+ """Get all managed states as a dictionary."""
1142
+ states = {}
1143
+ for file_ref in ManagedFile:
1144
+ state = self.get_state(file_ref)
1145
+ if state is not None:
1146
+ states[file_ref.name] = state
1147
+ return states
1148
+
1149
+ def refresh_cache(self) -> None:
1150
+ """Refresh all cached entries from disk."""
1151
+ with self._cache_lock:
1152
+ for path_str in list(self._cache.keys()):
1153
+ path = Path(path_str)
1154
+ if path.exists():
1155
+ data = self._read_file(path)
1156
+ if data:
1157
+ self._put_in_cache(path, data)
1158
+ else:
1159
+ del self._cache[path_str]
1160
+
1161
+ # -------------------------------------------------------------------------
1162
+ # Optimistic Updates (SYN-014)
1163
+ # -------------------------------------------------------------------------
1164
+
1165
+ def set_conflict_strategy(self, strategy: ConflictStrategy) -> None:
1166
+ """Set the default conflict resolution strategy."""
1167
+ self._conflict_strategy = strategy
1168
+
1169
+ def get_version_vector(
1170
+ self,
1171
+ file_ref: Union[str, ManagedFile]
1172
+ ) -> VersionVector:
1173
+ """Get the current version vector for a file."""
1174
+ path = self._resolve_path(file_ref)
1175
+ path_str = str(path)
1176
+
1177
+ if path_str not in self._version_vectors:
1178
+ # Try to load from file metadata
1179
+ state = self.get_state(file_ref)
1180
+ if state and "_version_vector" in state:
1181
+ self._version_vectors[path_str] = VersionVector.from_dict(
1182
+ state["_version_vector"]
1183
+ )
1184
+ else:
1185
+ self._version_vectors[path_str] = VersionVector()
1186
+
1187
+ return self._version_vectors[path_str]
1188
+
1189
+ def optimistic_update(
1190
+ self,
1191
+ file_ref: Union[str, ManagedFile],
1192
+ key: str,
1193
+ value: Any,
1194
+ source: str = "state-manager"
1195
+ ) -> PendingUpdate:
1196
+ """
1197
+ Apply an optimistic update immediately and queue for verification.
1198
+
1199
+ The update is applied to local state immediately but tracked as pending
1200
+ until verified against the canonical state. If conflicts are detected
1201
+ during verification, they are resolved using the configured strategy.
1202
+
1203
+ Args:
1204
+ file_ref: File reference (ManagedFile enum or relative path)
1205
+ key: The key to update within the state
1206
+ value: The new value
1207
+ source: Source of the update (for conflict detection)
1208
+
1209
+ Returns:
1210
+ PendingUpdate object tracking this update
1211
+ """
1212
+ path = self._resolve_path(file_ref)
1213
+ path_str = str(path)
1214
+
1215
+ # Get current version vector and increment for this source
1216
+ version_vector = self.get_version_vector(file_ref)
1217
+ version_vector.increment(source)
1218
+
1219
+ # Create pending update
1220
+ pending = PendingUpdate(
1221
+ id=str(uuid.uuid4()),
1222
+ key=key,
1223
+ value=value,
1224
+ source=source,
1225
+ timestamp=datetime.now(timezone.utc).isoformat(),
1226
+ version_vector=VersionVector.from_dict(version_vector.to_dict())
1227
+ )
1228
+
1229
+ # Track pending update
1230
+ if path_str not in self._pending_updates:
1231
+ self._pending_updates[path_str] = []
1232
+ self._pending_updates[path_str].append(pending)
1233
+
1234
+ # Apply optimistically to local state
1235
+ current_state = self.get_state(file_ref, default={})
1236
+ current_state[key] = value
1237
+ current_state["_version_vector"] = version_vector.to_dict()
1238
+ current_state["_last_source"] = source
1239
+ current_state["_last_updated"] = pending.timestamp
1240
+
1241
+ # Write state with version tracking
1242
+ self._write_file(path, current_state)
1243
+ self._put_in_cache(path, current_state)
1244
+
1245
+ return pending
1246
+
1247
+ def get_pending_updates(
1248
+ self,
1249
+ file_ref: Union[str, ManagedFile]
1250
+ ) -> List[PendingUpdate]:
1251
+ """Get all pending updates for a file."""
1252
+ path = self._resolve_path(file_ref)
1253
+ path_str = str(path)
1254
+ return self._pending_updates.get(path_str, [])
1255
+
1256
+ def detect_conflicts(
1257
+ self,
1258
+ file_ref: Union[str, ManagedFile],
1259
+ remote_state: Dict[str, Any],
1260
+ remote_source: str
1261
+ ) -> List[ConflictInfo]:
1262
+ """
1263
+ Detect conflicts between local pending updates and remote state.
1264
+
1265
+ Args:
1266
+ file_ref: File reference
1267
+ remote_state: State from remote/canonical source
1268
+ remote_source: Source identifier for remote state
1269
+
1270
+ Returns:
1271
+ List of detected conflicts
1272
+ """
1273
+ path = self._resolve_path(file_ref)
1274
+ path_str = str(path)
1275
+
1276
+ conflicts: List[ConflictInfo] = []
1277
+ pending = self._pending_updates.get(path_str, [])
1278
+
1279
+ if not pending:
1280
+ return conflicts
1281
+
1282
+ # Get remote version vector
1283
+ remote_vv = VersionVector()
1284
+ if "_version_vector" in remote_state:
1285
+ remote_vv = VersionVector.from_dict(remote_state["_version_vector"])
1286
+
1287
+ local_state = self.get_state(file_ref, default={})
1288
+
1289
+ # Check each pending update for conflicts
1290
+ for update in pending:
1291
+ if update.status != "pending":
1292
+ continue
1293
+
1294
+ key = update.key
1295
+
1296
+ # Check if same key was modified in remote state
1297
+ if key in remote_state and key in local_state:
1298
+ local_val = local_state[key]
1299
+ remote_val = remote_state[key]
1300
+
1301
+ # Only conflict if values differ and versions are concurrent
1302
+ if local_val != remote_val:
1303
+ if update.version_vector.concurrent_with(remote_vv):
1304
+ conflict = ConflictInfo(
1305
+ key=key,
1306
+ local_value=local_val,
1307
+ remote_value=remote_val,
1308
+ local_source=update.source,
1309
+ remote_source=remote_source,
1310
+ local_version=update.version_vector,
1311
+ remote_version=remote_vv
1312
+ )
1313
+ conflicts.append(conflict)
1314
+
1315
+ return conflicts
1316
+
1317
+ def resolve_conflicts(
1318
+ self,
1319
+ file_ref: Union[str, ManagedFile],
1320
+ conflicts: List[ConflictInfo],
1321
+ strategy: Optional[ConflictStrategy] = None
1322
+ ) -> Dict[str, Any]:
1323
+ """
1324
+ Resolve conflicts using the specified strategy.
1325
+
1326
+ Args:
1327
+ file_ref: File reference
1328
+ conflicts: List of conflicts to resolve
1329
+ strategy: Resolution strategy (uses default if not specified)
1330
+
1331
+ Returns:
1332
+ Resolved state dictionary
1333
+ """
1334
+ if strategy is None:
1335
+ strategy = self._conflict_strategy
1336
+
1337
+ path = self._resolve_path(file_ref)
1338
+ path_str = str(path)
1339
+
1340
+ local_state = self.get_state(file_ref, default={})
1341
+ resolved_state = dict(local_state)
1342
+
1343
+ for conflict in conflicts:
1344
+ if strategy == ConflictStrategy.LAST_WRITE_WINS:
1345
+ # Use remote value (assuming remote is more recent)
1346
+ resolved_state[conflict.key] = conflict.remote_value
1347
+ conflict.resolution = "last_write_wins"
1348
+ conflict.resolved_value = conflict.remote_value
1349
+
1350
+ elif strategy == ConflictStrategy.MERGE:
1351
+ # Attempt to merge values
1352
+ merged = self._merge_values(
1353
+ conflict.local_value,
1354
+ conflict.remote_value
1355
+ )
1356
+ resolved_state[conflict.key] = merged
1357
+ conflict.resolution = "merged"
1358
+ conflict.resolved_value = merged
1359
+
1360
+ elif strategy == ConflictStrategy.REJECT:
1361
+ # Keep local value, mark conflict as rejected
1362
+ conflict.resolution = "rejected"
1363
+ conflict.resolved_value = conflict.local_value
1364
+ # Mark pending updates for this key as rejected
1365
+ for update in self._pending_updates.get(path_str, []):
1366
+ if update.key == conflict.key and update.status == "pending":
1367
+ update.status = "rejected"
1368
+
1369
+ # Merge version vectors
1370
+ local_vv = self.get_version_vector(file_ref)
1371
+ for conflict in conflicts:
1372
+ local_vv = local_vv.merge(conflict.remote_version)
1373
+
1374
+ resolved_state["_version_vector"] = local_vv.to_dict()
1375
+ self._version_vectors[path_str] = local_vv
1376
+
1377
+ return resolved_state
1378
+
1379
+ def _merge_values(self, local: Any, remote: Any) -> Any:
1380
+ """
1381
+ Attempt to merge two values.
1382
+
1383
+ For dictionaries, performs a deep merge.
1384
+ For lists, concatenates and deduplicates.
1385
+ For other types, prefers remote value.
1386
+ """
1387
+ if isinstance(local, dict) and isinstance(remote, dict):
1388
+ merged = dict(local)
1389
+ for key, value in remote.items():
1390
+ if key in merged:
1391
+ merged[key] = self._merge_values(merged[key], value)
1392
+ else:
1393
+ merged[key] = value
1394
+ return merged
1395
+
1396
+ elif isinstance(local, list) and isinstance(remote, list):
1397
+ # Concatenate and deduplicate (preserving order)
1398
+ seen = set()
1399
+ merged = []
1400
+ for item in local + remote:
1401
+ item_key = json.dumps(item, sort_keys=True, default=str) if isinstance(item, (dict, list)) else item
1402
+ if item_key not in seen:
1403
+ seen.add(item_key)
1404
+ merged.append(item)
1405
+ return merged
1406
+
1407
+ else:
1408
+ # For scalars, prefer remote
1409
+ return remote
1410
+
1411
+ def commit_pending_updates(
1412
+ self,
1413
+ file_ref: Union[str, ManagedFile]
1414
+ ) -> int:
1415
+ """
1416
+ Commit all pending updates for a file.
1417
+
1418
+ This marks pending updates as committed and clears them from tracking.
1419
+
1420
+ Args:
1421
+ file_ref: File reference
1422
+
1423
+ Returns:
1424
+ Number of updates committed
1425
+ """
1426
+ path = self._resolve_path(file_ref)
1427
+ path_str = str(path)
1428
+
1429
+ pending = self._pending_updates.get(path_str, [])
1430
+ committed = 0
1431
+
1432
+ for update in pending:
1433
+ if update.status == "pending":
1434
+ update.status = "committed"
1435
+ committed += 1
1436
+
1437
+ # Clear committed updates
1438
+ self._pending_updates[path_str] = [
1439
+ u for u in pending if u.status != "committed"
1440
+ ]
1441
+
1442
+ return committed
1443
+
1444
+ def rollback_pending_updates(
1445
+ self,
1446
+ file_ref: Union[str, ManagedFile],
1447
+ original_state: Dict[str, Any]
1448
+ ) -> int:
1449
+ """
1450
+ Rollback pending updates and restore original state.
1451
+
1452
+ Args:
1453
+ file_ref: File reference
1454
+ original_state: State to restore
1455
+
1456
+ Returns:
1457
+ Number of updates rolled back
1458
+ """
1459
+ path = self._resolve_path(file_ref)
1460
+ path_str = str(path)
1461
+
1462
+ pending = self._pending_updates.get(path_str, [])
1463
+ rolled_back = 0
1464
+
1465
+ for update in pending:
1466
+ if update.status == "pending":
1467
+ update.status = "rejected"
1468
+ rolled_back += 1
1469
+
1470
+ # Restore original state
1471
+ self.set_state(file_ref, original_state, source="rollback")
1472
+
1473
+ # Clear pending updates
1474
+ self._pending_updates[path_str] = []
1475
+
1476
+ return rolled_back
1477
+
1478
+ def sync_with_remote(
1479
+ self,
1480
+ file_ref: Union[str, ManagedFile],
1481
+ remote_state: Dict[str, Any],
1482
+ remote_source: str,
1483
+ strategy: Optional[ConflictStrategy] = None
1484
+ ) -> Tuple[Dict[str, Any], List[ConflictInfo], int]:
1485
+ """
1486
+ Synchronize local state with remote state, resolving conflicts.
1487
+
1488
+ This is a high-level operation that:
1489
+ 1. Detects conflicts between local pending updates and remote state
1490
+ 2. Resolves conflicts using the specified strategy
1491
+ 3. Commits or rejects pending updates accordingly
1492
+ 4. Returns the final synchronized state
1493
+
1494
+ Args:
1495
+ file_ref: File reference
1496
+ remote_state: State from remote/canonical source
1497
+ remote_source: Source identifier for remote state
1498
+ strategy: Conflict resolution strategy
1499
+
1500
+ Returns:
1501
+ Tuple of (resolved_state, conflicts_resolved, updates_committed)
1502
+ """
1503
+ # Detect conflicts
1504
+ conflicts = self.detect_conflicts(file_ref, remote_state, remote_source)
1505
+
1506
+ # Resolve conflicts
1507
+ resolved_state = self.resolve_conflicts(file_ref, conflicts, strategy)
1508
+
1509
+ # Apply resolved state
1510
+ self.set_state(file_ref, resolved_state, source="sync")
1511
+
1512
+ # Commit pending updates (non-rejected ones)
1513
+ committed = self.commit_pending_updates(file_ref)
1514
+
1515
+ return resolved_state, conflicts, committed
1516
+
1517
+ # -------------------------------------------------------------------------
1518
+ # State Versioning (SYN-015)
1519
+ # -------------------------------------------------------------------------
1520
+
1521
+ def _get_file_key(self, file_ref: Union[str, ManagedFile]) -> str:
1522
+ """Get a safe key for the file reference (used in history paths)."""
1523
+ if isinstance(file_ref, ManagedFile):
1524
+ rel_path = file_ref.value
1525
+ else:
1526
+ rel_path = file_ref
1527
+ # Convert path separators to underscores for safe directory names
1528
+ return rel_path.replace("/", "_").replace("\\", "_").replace(".json", "")
1529
+
1530
+ def _get_history_dir(self, file_ref: Union[str, ManagedFile]) -> Path:
1531
+ """Get the history directory for a file reference."""
1532
+ file_key = self._get_file_key(file_ref)
1533
+ return self.loki_dir / "state" / "history" / file_key
1534
+
1535
+ def _get_next_version(self, file_ref: Union[str, ManagedFile]) -> int:
1536
+ """Get the next version number for a file."""
1537
+ file_key = self._get_file_key(file_ref)
1538
+ if file_key not in self._version_counters:
1539
+ # Initialize from existing versions on disk
1540
+ history_dir = self._get_history_dir(file_ref)
1541
+ if history_dir.exists():
1542
+ versions = glob_module.glob(str(history_dir / "*.json"))
1543
+ if versions:
1544
+ max_version = 0
1545
+ for v in versions:
1546
+ try:
1547
+ version_num = int(Path(v).stem)
1548
+ max_version = max(max_version, version_num)
1549
+ except ValueError:
1550
+ pass
1551
+ self._version_counters[file_key] = max_version
1552
+ else:
1553
+ self._version_counters[file_key] = 0
1554
+ else:
1555
+ self._version_counters[file_key] = 0
1556
+ self._version_counters[file_key] += 1
1557
+ return self._version_counters[file_key]
1558
+
1559
+ def _save_version(
1560
+ self,
1561
+ file_ref: Union[str, ManagedFile],
1562
+ data: Dict[str, Any],
1563
+ source: str,
1564
+ change_type: str
1565
+ ) -> int:
1566
+ """
1567
+ Save a version of the state to history.
1568
+
1569
+ Args:
1570
+ file_ref: File reference
1571
+ data: State data to save
1572
+ source: Source of the change
1573
+ change_type: Type of change (create, update, delete)
1574
+
1575
+ Returns:
1576
+ Version number
1577
+ """
1578
+ history_dir = self._get_history_dir(file_ref)
1579
+ history_dir.mkdir(parents=True, exist_ok=True)
1580
+
1581
+ version = self._get_next_version(file_ref)
1582
+ timestamp = datetime.now(timezone.utc).isoformat()
1583
+
1584
+ version_data = StateVersion(
1585
+ version=version,
1586
+ timestamp=timestamp,
1587
+ data=data,
1588
+ source=source,
1589
+ change_type=change_type
1590
+ )
1591
+
1592
+ version_path = history_dir / f"{version}.json"
1593
+ self._write_file(version_path, version_data.to_dict())
1594
+
1595
+ # Clean up old versions
1596
+ self._cleanup_old_versions(file_ref)
1597
+
1598
+ return version
1599
+
1600
+ def _cleanup_old_versions(self, file_ref: Union[str, ManagedFile]) -> None:
1601
+ """Remove versions beyond the retention limit."""
1602
+ history_dir = self._get_history_dir(file_ref)
1603
+ if not history_dir.exists():
1604
+ return
1605
+
1606
+ version_files = glob_module.glob(str(history_dir / "*.json"))
1607
+ if len(version_files) <= self.version_retention:
1608
+ return
1609
+
1610
+ # Sort by version number and remove oldest
1611
+ version_nums = []
1612
+ for vf in version_files:
1613
+ try:
1614
+ version_num = int(Path(vf).stem)
1615
+ version_nums.append((version_num, vf))
1616
+ except ValueError:
1617
+ pass
1618
+
1619
+ version_nums.sort(key=lambda x: x[0])
1620
+ to_remove = version_nums[:-self.version_retention]
1621
+
1622
+ for _, vf in to_remove:
1623
+ try:
1624
+ os.unlink(vf)
1625
+ except OSError:
1626
+ pass
1627
+
1628
+ def get_version_history(
1629
+ self,
1630
+ file_ref: Union[str, ManagedFile]
1631
+ ) -> List[VersionInfo]:
1632
+ """
1633
+ Get version history for a state file.
1634
+
1635
+ Args:
1636
+ file_ref: File reference (ManagedFile enum or relative path)
1637
+
1638
+ Returns:
1639
+ List of VersionInfo objects sorted by version (newest first)
1640
+ """
1641
+ history_dir = self._get_history_dir(file_ref)
1642
+ if not history_dir.exists():
1643
+ return []
1644
+
1645
+ versions = []
1646
+ version_files = glob_module.glob(str(history_dir / "*.json"))
1647
+
1648
+ for vf in version_files:
1649
+ try:
1650
+ version_num = int(Path(vf).stem)
1651
+ data = self._read_file(Path(vf))
1652
+ if data:
1653
+ versions.append(VersionInfo(
1654
+ version=version_num,
1655
+ timestamp=data.get("timestamp", ""),
1656
+ source=data.get("source", "unknown"),
1657
+ change_type=data.get("change_type", "update"),
1658
+ data_hash=self._compute_hash(data.get("data", {}))
1659
+ ))
1660
+ except (ValueError, json.JSONDecodeError):
1661
+ pass
1662
+
1663
+ # Sort by version descending (newest first)
1664
+ versions.sort(key=lambda v: v.version, reverse=True)
1665
+ return versions
1666
+
1667
+ def get_state_at_version(
1668
+ self,
1669
+ file_ref: Union[str, ManagedFile],
1670
+ version: int
1671
+ ) -> Optional[Dict[str, Any]]:
1672
+ """
1673
+ Get state data at a specific version without restoring.
1674
+
1675
+ Args:
1676
+ file_ref: File reference
1677
+ version: Version number to retrieve
1678
+
1679
+ Returns:
1680
+ State data at that version or None if not found
1681
+ """
1682
+ history_dir = self._get_history_dir(file_ref)
1683
+ version_path = history_dir / f"{version}.json"
1684
+
1685
+ if not version_path.exists():
1686
+ return None
1687
+
1688
+ version_data = self._read_file(version_path)
1689
+ if version_data:
1690
+ return version_data.get("data")
1691
+ return None
1692
+
1693
+ def rollback(
1694
+ self,
1695
+ file_ref: Union[str, ManagedFile],
1696
+ version: int,
1697
+ source: str = "rollback"
1698
+ ) -> Optional[StateChange]:
1699
+ """
1700
+ Restore state to a specific version.
1701
+
1702
+ Args:
1703
+ file_ref: File reference
1704
+ version: Version number to restore to
1705
+ source: Source of the rollback operation
1706
+
1707
+ Returns:
1708
+ StateChange object or None if version not found
1709
+ """
1710
+ data = self.get_state_at_version(file_ref, version)
1711
+ if data is None:
1712
+ return None
1713
+
1714
+ # Save current state as a version before rollback
1715
+ current = self.get_state(file_ref)
1716
+ if current is not None and self.enable_versioning:
1717
+ self._save_version(file_ref, current, source, "pre_rollback")
1718
+
1719
+ # Set the restored state (save_version=False since we already saved)
1720
+ return self.set_state(file_ref, data, source, save_version=False)
1721
+
1722
+ def get_version_count(self, file_ref: Union[str, ManagedFile]) -> int:
1723
+ """
1724
+ Get the number of versions stored for a file.
1725
+
1726
+ Args:
1727
+ file_ref: File reference
1728
+
1729
+ Returns:
1730
+ Number of stored versions
1731
+ """
1732
+ history_dir = self._get_history_dir(file_ref)
1733
+ if not history_dir.exists():
1734
+ return 0
1735
+ return len(glob_module.glob(str(history_dir / "*.json")))
1736
+
1737
+ def clear_version_history(self, file_ref: Union[str, ManagedFile]) -> int:
1738
+ """
1739
+ Clear all version history for a file.
1740
+
1741
+ Args:
1742
+ file_ref: File reference
1743
+
1744
+ Returns:
1745
+ Number of versions removed
1746
+ """
1747
+ history_dir = self._get_history_dir(file_ref)
1748
+ if not history_dir.exists():
1749
+ return 0
1750
+
1751
+ version_files = glob_module.glob(str(history_dir / "*.json"))
1752
+ count = 0
1753
+ for vf in version_files:
1754
+ try:
1755
+ os.unlink(vf)
1756
+ count += 1
1757
+ except OSError:
1758
+ pass
1759
+
1760
+ # Reset version counter
1761
+ file_key = self._get_file_key(file_ref)
1762
+ self._version_counters.pop(file_key, None)
1763
+
1764
+ return count
1765
+
1766
+ def set_version_retention(self, retention: int) -> None:
1767
+ """
1768
+ Update the version retention limit.
1769
+
1770
+ Args:
1771
+ retention: New retention limit (must be >= 1)
1772
+ """
1773
+ if retention < 1:
1774
+ raise ValueError("Version retention must be at least 1")
1775
+ self.version_retention = retention
1776
+
1777
+
1778
+ # Singleton instance for convenience
1779
+ _default_manager: Optional[StateManager] = None
1780
+
1781
+
1782
+ def get_state_manager(
1783
+ loki_dir: Optional[Union[str, Path]] = None,
1784
+ **kwargs
1785
+ ) -> StateManager:
1786
+ """Get the default state manager instance."""
1787
+ global _default_manager
1788
+
1789
+ if _default_manager is None:
1790
+ _default_manager = StateManager(loki_dir, **kwargs)
1791
+
1792
+ return _default_manager
1793
+
1794
+
1795
+ def reset_state_manager() -> None:
1796
+ """Reset the default state manager (for testing)."""
1797
+ global _default_manager
1798
+
1799
+ if _default_manager:
1800
+ _default_manager.stop()
1801
+ _default_manager = None