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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +34 -8
- package/autonomy/completion-council.sh +70 -32
- package/autonomy/issue-parser.sh +4 -7
- package/autonomy/loki +238 -119
- package/autonomy/notification-checker.py +49 -23
- package/autonomy/run.sh +162 -79
- package/autonomy/sandbox.sh +91 -24
- package/bin/loki-mode.js +1 -2
- package/bin/postinstall.js +10 -4
- package/dashboard/__init__.py +1 -1
- package/dashboard/control.py +46 -36
- package/dashboard/database.py +21 -4
- package/dashboard/server.py +107 -78
- package/docs/BUG-AUDIT-v6.61.0.md +957 -0
- package/docs/INSTALLATION.md +2 -2
- package/events/bus.py +129 -28
- package/events/bus.ts +41 -27
- package/events/emit.sh +1 -1
- package/integrations/openclaw/README.md +139 -0
- package/integrations/openclaw/SKILL.md +88 -0
- package/integrations/openclaw/bridge/__init__.py +1 -0
- package/integrations/openclaw/bridge/__main__.py +88 -0
- package/integrations/openclaw/bridge/schema_map.py +180 -0
- package/integrations/openclaw/bridge/watcher.py +100 -0
- package/integrations/openclaw/scripts/format-progress.sh +80 -0
- package/integrations/openclaw/scripts/poll-status.sh +74 -0
- package/integrations/vibe-kanban.md +289 -0
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +96 -73
- package/memory/consolidation.py +21 -6
- package/memory/engine.py +53 -26
- package/memory/layers/index_layer.py +16 -3
- package/memory/layers/timeline_layer.py +16 -3
- package/memory/retrieval.py +4 -1
- package/memory/schemas.py +4 -2
- package/memory/storage.py +25 -4
- package/memory/token_economics.py +9 -2
- package/memory/vector_index.py +2 -2
- package/package.json +3 -1
- package/providers/cline.sh +5 -4
- package/providers/codex.sh +27 -5
- package/providers/gemini.sh +59 -23
- package/providers/loader.sh +3 -2
- package/skills/parallel-workflows.md +9 -7
- package/state/__init__.py +10 -0
- package/state/index.ts +18 -0
- package/state/manager.py +1801 -0
- package/state/manager.ts +1774 -0
- package/state/sqlite_backend.py +188 -0
- package/state/test_manager.py +703 -0
- package/state/test_manager.ts +366 -0
- package/templates/README.md +19 -4
- package/templates/dashboard.md +45 -0
- package/templates/data-pipeline.md +45 -0
- package/templates/game.md +48 -0
- package/templates/microservice.md +49 -0
- package/templates/npm-library.md +42 -0
- package/templates/rest-api.md +170 -33
- package/templates/slack-bot.md +48 -0
- package/templates/web-scraper.md +45 -0
- package/web-app/server.py +360 -191
- package/templates/saas-app.md +0 -42
package/state/manager.py
ADDED
|
@@ -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
|