htmlgraph 0.27.6__py3-none-any.whl → 0.28.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- htmlgraph/__init__.py +9 -1
- htmlgraph/api/broadcast.py +316 -0
- htmlgraph/api/broadcast_routes.py +357 -0
- htmlgraph/api/broadcast_websocket.py +115 -0
- htmlgraph/api/cost_alerts_websocket.py +7 -16
- htmlgraph/api/main.py +110 -1
- htmlgraph/api/offline.py +776 -0
- htmlgraph/api/presence.py +446 -0
- htmlgraph/api/reactive.py +455 -0
- htmlgraph/api/reactive_routes.py +195 -0
- htmlgraph/api/static/broadcast-demo.html +393 -0
- htmlgraph/api/sync_routes.py +184 -0
- htmlgraph/api/websocket.py +112 -37
- htmlgraph/broadcast_integration.py +227 -0
- htmlgraph/cli_commands/sync.py +207 -0
- htmlgraph/db/schema.py +214 -0
- htmlgraph/hooks/event_tracker.py +53 -2
- htmlgraph/reactive_integration.py +148 -0
- htmlgraph/session_context.py +1669 -0
- htmlgraph/session_manager.py +70 -0
- htmlgraph/sync/__init__.py +21 -0
- htmlgraph/sync/git_sync.py +458 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/METADATA +1 -1
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/RECORD +31 -16
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/WHEEL +0 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/entry_points.txt +0 -0
htmlgraph/session_manager.py
CHANGED
|
@@ -2801,3 +2801,73 @@ class SessionManager:
|
|
|
2801
2801
|
"has_thinking_traces": transcript.has_thinking_traces(),
|
|
2802
2802
|
"entry_count": len(transcript.entries),
|
|
2803
2803
|
}
|
|
2804
|
+
|
|
2805
|
+
# =========================================================================
|
|
2806
|
+
# Session Context Builder - Delegates to SessionContextBuilder
|
|
2807
|
+
# =========================================================================
|
|
2808
|
+
|
|
2809
|
+
def get_version_status(self) -> dict[str, Any]:
|
|
2810
|
+
"""
|
|
2811
|
+
Check installed htmlgraph version against latest on PyPI.
|
|
2812
|
+
|
|
2813
|
+
Returns:
|
|
2814
|
+
Dict with installed_version, latest_version, is_outdated
|
|
2815
|
+
"""
|
|
2816
|
+
from htmlgraph.session_context import VersionChecker
|
|
2817
|
+
|
|
2818
|
+
return VersionChecker.get_version_status()
|
|
2819
|
+
|
|
2820
|
+
def initialize_git_hooks(self, project_dir: str | Path) -> bool:
|
|
2821
|
+
"""
|
|
2822
|
+
Install pre-commit hooks if not already installed.
|
|
2823
|
+
|
|
2824
|
+
Args:
|
|
2825
|
+
project_dir: Path to the project root
|
|
2826
|
+
|
|
2827
|
+
Returns:
|
|
2828
|
+
True if hooks were installed or already exist
|
|
2829
|
+
"""
|
|
2830
|
+
from htmlgraph.session_context import GitHooksInstaller
|
|
2831
|
+
|
|
2832
|
+
return GitHooksInstaller.install(project_dir)
|
|
2833
|
+
|
|
2834
|
+
def get_start_context(
|
|
2835
|
+
self,
|
|
2836
|
+
session_id: str,
|
|
2837
|
+
project_dir: str | Path | None = None,
|
|
2838
|
+
compute_async: bool = True,
|
|
2839
|
+
) -> str:
|
|
2840
|
+
"""
|
|
2841
|
+
Build complete session start context for AI agents.
|
|
2842
|
+
|
|
2843
|
+
This is the primary method for generating the full context string
|
|
2844
|
+
that gets injected via additionalContext in the SessionStart hook.
|
|
2845
|
+
|
|
2846
|
+
Args:
|
|
2847
|
+
session_id: Current session ID
|
|
2848
|
+
project_dir: Project root directory (uses graph_dir parent if not provided)
|
|
2849
|
+
compute_async: Use parallel async operations for performance
|
|
2850
|
+
|
|
2851
|
+
Returns:
|
|
2852
|
+
Complete formatted Markdown context string
|
|
2853
|
+
"""
|
|
2854
|
+
from htmlgraph.session_context import SessionContextBuilder
|
|
2855
|
+
|
|
2856
|
+
if project_dir is None:
|
|
2857
|
+
project_dir = self.graph_dir.parent
|
|
2858
|
+
|
|
2859
|
+
builder = SessionContextBuilder(self.graph_dir, project_dir)
|
|
2860
|
+
return builder.build(session_id=session_id, compute_async=compute_async)
|
|
2861
|
+
|
|
2862
|
+
def detect_feature_conflicts(self) -> list[dict[str, Any]]:
|
|
2863
|
+
"""
|
|
2864
|
+
Detect features being worked on by multiple agents simultaneously.
|
|
2865
|
+
|
|
2866
|
+
Returns:
|
|
2867
|
+
List of conflict dicts with feature_id, title, agents
|
|
2868
|
+
"""
|
|
2869
|
+
from htmlgraph.session_context import SessionContextBuilder
|
|
2870
|
+
|
|
2871
|
+
project_dir = self.graph_dir.parent
|
|
2872
|
+
builder = SessionContextBuilder(self.graph_dir, project_dir)
|
|
2873
|
+
return builder.detect_feature_conflicts()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git-based synchronization for multi-device continuity.
|
|
3
|
+
|
|
4
|
+
Enables automatic sync of .htmlgraph/ directory across devices via Git.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .git_sync import (
|
|
8
|
+
GitSyncManager,
|
|
9
|
+
SyncConfig,
|
|
10
|
+
SyncResult,
|
|
11
|
+
SyncStatus,
|
|
12
|
+
SyncStrategy,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"GitSyncManager",
|
|
17
|
+
"SyncConfig",
|
|
18
|
+
"SyncStrategy",
|
|
19
|
+
"SyncStatus",
|
|
20
|
+
"SyncResult",
|
|
21
|
+
]
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git-based synchronization manager for multi-device continuity.
|
|
3
|
+
|
|
4
|
+
Provides automatic push/pull of .htmlgraph/ directory with conflict resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SyncStrategy(str, Enum):
|
|
21
|
+
"""Conflict resolution strategies."""
|
|
22
|
+
|
|
23
|
+
AUTO_MERGE = "auto_merge" # Let git auto-merge
|
|
24
|
+
ABORT_ON_CONFLICT = "abort_on_conflict" # Fail if conflicts
|
|
25
|
+
OURS = "ours" # Keep local on conflict
|
|
26
|
+
THEIRS = "theirs" # Take remote on conflict
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SyncStatus(str, Enum):
|
|
30
|
+
"""Current sync operation status."""
|
|
31
|
+
|
|
32
|
+
IDLE = "idle"
|
|
33
|
+
PUSHING = "pushing"
|
|
34
|
+
PULLING = "pulling"
|
|
35
|
+
CONFLICT = "conflict"
|
|
36
|
+
ERROR = "error"
|
|
37
|
+
SUCCESS = "success"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SyncConfig:
|
|
42
|
+
"""Configuration for Git sync."""
|
|
43
|
+
|
|
44
|
+
push_interval_seconds: int = 300 # 5 min
|
|
45
|
+
pull_interval_seconds: int = 60 # 1 min
|
|
46
|
+
remote_name: str = "origin"
|
|
47
|
+
branch_name: str = "main"
|
|
48
|
+
conflict_strategy: SyncStrategy = SyncStrategy.AUTO_MERGE
|
|
49
|
+
auto_stash: bool = True # Stash uncommitted changes before pull
|
|
50
|
+
sync_path: str = ".htmlgraph" # Path to sync within repo
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SyncResult:
|
|
55
|
+
"""Result of a sync operation."""
|
|
56
|
+
|
|
57
|
+
status: SyncStatus
|
|
58
|
+
operation: str # "push" or "pull"
|
|
59
|
+
timestamp: datetime
|
|
60
|
+
files_changed: int = 0
|
|
61
|
+
conflicts: list[str] = field(default_factory=list)
|
|
62
|
+
message: str = ""
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
"""Convert to dictionary for JSON serialization."""
|
|
66
|
+
return {
|
|
67
|
+
"status": self.status.value,
|
|
68
|
+
"operation": self.operation,
|
|
69
|
+
"timestamp": self.timestamp.isoformat(),
|
|
70
|
+
"files_changed": self.files_changed,
|
|
71
|
+
"conflicts": self.conflicts,
|
|
72
|
+
"message": self.message,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class GitSyncManager:
|
|
77
|
+
"""Manages automatic Git sync of .htmlgraph/ directory."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, repo_root: str, config: SyncConfig | None = None):
|
|
80
|
+
"""
|
|
81
|
+
Initialize Git sync manager.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
repo_root: Path to git repository root
|
|
85
|
+
config: Sync configuration (uses defaults if None)
|
|
86
|
+
"""
|
|
87
|
+
self.repo_root = Path(repo_root)
|
|
88
|
+
self.htmlgraph_dir = self.repo_root / ".htmlgraph"
|
|
89
|
+
self.config = config or SyncConfig()
|
|
90
|
+
self.last_push: datetime | None = None
|
|
91
|
+
self.last_pull: datetime | None = None
|
|
92
|
+
self.status = SyncStatus.IDLE
|
|
93
|
+
self.sync_history: list[SyncResult] = []
|
|
94
|
+
self._running = False
|
|
95
|
+
self._push_task: asyncio.Task | None = None
|
|
96
|
+
self._pull_task: asyncio.Task | None = None
|
|
97
|
+
|
|
98
|
+
async def start_background_sync(self) -> None:
|
|
99
|
+
"""Start background sync tasks."""
|
|
100
|
+
if self._running:
|
|
101
|
+
logger.warning("Background sync already running")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
logger.info("Starting background sync service...")
|
|
105
|
+
self._running = True
|
|
106
|
+
|
|
107
|
+
# Create sync tasks
|
|
108
|
+
self._push_task = asyncio.create_task(self._push_loop())
|
|
109
|
+
self._pull_task = asyncio.create_task(self._pull_loop())
|
|
110
|
+
|
|
111
|
+
# Wait for both tasks
|
|
112
|
+
try:
|
|
113
|
+
await asyncio.gather(self._push_task, self._pull_task)
|
|
114
|
+
except asyncio.CancelledError:
|
|
115
|
+
logger.info("Background sync cancelled")
|
|
116
|
+
|
|
117
|
+
async def stop_background_sync(self) -> None:
|
|
118
|
+
"""Stop background sync tasks."""
|
|
119
|
+
logger.info("Stopping background sync service...")
|
|
120
|
+
self._running = False
|
|
121
|
+
|
|
122
|
+
if self._push_task:
|
|
123
|
+
self._push_task.cancel()
|
|
124
|
+
try:
|
|
125
|
+
await self._push_task
|
|
126
|
+
except asyncio.CancelledError:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
if self._pull_task:
|
|
130
|
+
self._pull_task.cancel()
|
|
131
|
+
try:
|
|
132
|
+
await self._pull_task
|
|
133
|
+
except asyncio.CancelledError:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
async def _push_loop(self) -> None:
|
|
137
|
+
"""Periodically push changes to remote."""
|
|
138
|
+
while self._running:
|
|
139
|
+
try:
|
|
140
|
+
await asyncio.sleep(self.config.push_interval_seconds)
|
|
141
|
+
if self._running: # Check again after sleep
|
|
142
|
+
await self.push()
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Push error: {e}")
|
|
145
|
+
self.status = SyncStatus.ERROR
|
|
146
|
+
|
|
147
|
+
async def _pull_loop(self) -> None:
|
|
148
|
+
"""Periodically pull changes from remote."""
|
|
149
|
+
while self._running:
|
|
150
|
+
try:
|
|
151
|
+
await asyncio.sleep(self.config.pull_interval_seconds)
|
|
152
|
+
if self._running: # Check again after sleep
|
|
153
|
+
await self.pull()
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.error(f"Pull error: {e}")
|
|
156
|
+
self.status = SyncStatus.ERROR
|
|
157
|
+
|
|
158
|
+
async def push(self, force: bool = False) -> SyncResult:
|
|
159
|
+
"""
|
|
160
|
+
Push local changes to remote.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
force: Force push even if recently pushed
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
SyncResult with operation details
|
|
167
|
+
"""
|
|
168
|
+
# Skip if pushed recently (unless forced)
|
|
169
|
+
if not force and self.last_push:
|
|
170
|
+
elapsed = (datetime.now() - self.last_push).total_seconds()
|
|
171
|
+
if elapsed < self.config.push_interval_seconds:
|
|
172
|
+
return SyncResult(
|
|
173
|
+
status=SyncStatus.IDLE,
|
|
174
|
+
operation="push",
|
|
175
|
+
timestamp=datetime.now(),
|
|
176
|
+
message=f"Skip: {int(elapsed)}s since last push",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
self.status = SyncStatus.PUSHING
|
|
181
|
+
|
|
182
|
+
# Check for uncommitted changes in .htmlgraph
|
|
183
|
+
result = await self._run_git(
|
|
184
|
+
["status", "--porcelain", str(self.config.sync_path)]
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not result:
|
|
188
|
+
self.status = SyncStatus.IDLE
|
|
189
|
+
sync_result = SyncResult(
|
|
190
|
+
status=SyncStatus.SUCCESS,
|
|
191
|
+
operation="push",
|
|
192
|
+
timestamp=datetime.now(),
|
|
193
|
+
message="No changes to push",
|
|
194
|
+
)
|
|
195
|
+
self.sync_history.append(sync_result)
|
|
196
|
+
return sync_result
|
|
197
|
+
|
|
198
|
+
# Stage changes
|
|
199
|
+
await self._run_git(["add", str(self.config.sync_path)])
|
|
200
|
+
|
|
201
|
+
# Commit
|
|
202
|
+
commit_msg = f"chore: auto-sync htmlgraph from {self._get_hostname()}"
|
|
203
|
+
await self._run_git(["commit", "-m", commit_msg])
|
|
204
|
+
|
|
205
|
+
# Push to remote
|
|
206
|
+
await self._run_git(
|
|
207
|
+
[
|
|
208
|
+
"push",
|
|
209
|
+
self.config.remote_name,
|
|
210
|
+
f"{self.config.branch_name}:refs/heads/{self.config.branch_name}",
|
|
211
|
+
]
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
self.last_push = datetime.now()
|
|
215
|
+
self.status = SyncStatus.SUCCESS
|
|
216
|
+
|
|
217
|
+
logger.info(f"Pushed changes to {self.config.remote_name}")
|
|
218
|
+
|
|
219
|
+
sync_result = SyncResult(
|
|
220
|
+
status=SyncStatus.SUCCESS,
|
|
221
|
+
operation="push",
|
|
222
|
+
timestamp=self.last_push,
|
|
223
|
+
files_changed=len(result.strip().split("\n")),
|
|
224
|
+
message=f"Pushed {len(result.strip().split(chr(10)))} files",
|
|
225
|
+
)
|
|
226
|
+
self.sync_history.append(sync_result)
|
|
227
|
+
return sync_result
|
|
228
|
+
|
|
229
|
+
except Exception as e:
|
|
230
|
+
self.status = SyncStatus.ERROR
|
|
231
|
+
logger.error(f"Push failed: {e}")
|
|
232
|
+
|
|
233
|
+
sync_result = SyncResult(
|
|
234
|
+
status=SyncStatus.ERROR,
|
|
235
|
+
operation="push",
|
|
236
|
+
timestamp=datetime.now(),
|
|
237
|
+
message=str(e),
|
|
238
|
+
)
|
|
239
|
+
self.sync_history.append(sync_result)
|
|
240
|
+
return sync_result
|
|
241
|
+
|
|
242
|
+
async def pull(self, force: bool = False) -> SyncResult:
|
|
243
|
+
"""
|
|
244
|
+
Pull remote changes.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
force: Force pull even if recently pulled
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
SyncResult with operation details
|
|
251
|
+
"""
|
|
252
|
+
# Skip if pulled recently (unless forced)
|
|
253
|
+
if not force and self.last_pull:
|
|
254
|
+
elapsed = (datetime.now() - self.last_pull).total_seconds()
|
|
255
|
+
if elapsed < self.config.pull_interval_seconds:
|
|
256
|
+
return SyncResult(
|
|
257
|
+
status=SyncStatus.IDLE,
|
|
258
|
+
operation="pull",
|
|
259
|
+
timestamp=datetime.now(),
|
|
260
|
+
message=f"Skip: {int(elapsed)}s since last pull",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
self.status = SyncStatus.PULLING
|
|
265
|
+
|
|
266
|
+
# Stash uncommitted changes if enabled
|
|
267
|
+
if self.config.auto_stash:
|
|
268
|
+
await self._run_git(
|
|
269
|
+
["stash", "push", "-m", "auto-stash before pull"], allow_fail=True
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Fetch latest from remote
|
|
273
|
+
await self._run_git(["fetch", self.config.remote_name])
|
|
274
|
+
|
|
275
|
+
# Try to merge
|
|
276
|
+
try:
|
|
277
|
+
await self._run_git(
|
|
278
|
+
[
|
|
279
|
+
"merge",
|
|
280
|
+
f"{self.config.remote_name}/{self.config.branch_name}",
|
|
281
|
+
"-m",
|
|
282
|
+
"auto-merge from remote",
|
|
283
|
+
]
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
self.last_pull = datetime.now()
|
|
287
|
+
self.status = SyncStatus.SUCCESS
|
|
288
|
+
|
|
289
|
+
logger.info(f"Pulled changes from {self.config.remote_name}")
|
|
290
|
+
|
|
291
|
+
sync_result = SyncResult(
|
|
292
|
+
status=SyncStatus.SUCCESS,
|
|
293
|
+
operation="pull",
|
|
294
|
+
timestamp=self.last_pull,
|
|
295
|
+
message="Merged successfully",
|
|
296
|
+
)
|
|
297
|
+
self.sync_history.append(sync_result)
|
|
298
|
+
return sync_result
|
|
299
|
+
|
|
300
|
+
except subprocess.CalledProcessError:
|
|
301
|
+
# Merge conflict
|
|
302
|
+
conflicts = await self._get_conflicts()
|
|
303
|
+
self.status = SyncStatus.CONFLICT
|
|
304
|
+
|
|
305
|
+
logger.warning(f"Merge conflict detected: {conflicts}")
|
|
306
|
+
|
|
307
|
+
# Handle conflict based on strategy
|
|
308
|
+
if self.config.conflict_strategy == SyncStrategy.AUTO_MERGE:
|
|
309
|
+
await self._resolve_conflicts_auto()
|
|
310
|
+
await self._run_git(["commit", "-m", "auto-resolve merge conflict"])
|
|
311
|
+
elif self.config.conflict_strategy == SyncStrategy.OURS:
|
|
312
|
+
await self._run_git(["checkout", "--ours", "."])
|
|
313
|
+
await self._run_git(["add", "."])
|
|
314
|
+
await self._run_git(["commit", "-m", "conflict: keep local"])
|
|
315
|
+
elif self.config.conflict_strategy == SyncStrategy.THEIRS:
|
|
316
|
+
await self._run_git(["checkout", "--theirs", "."])
|
|
317
|
+
await self._run_git(["add", "."])
|
|
318
|
+
await self._run_git(["commit", "-m", "conflict: keep remote"])
|
|
319
|
+
elif self.config.conflict_strategy == SyncStrategy.ABORT_ON_CONFLICT:
|
|
320
|
+
await self._run_git(["merge", "--abort"])
|
|
321
|
+
|
|
322
|
+
sync_result = SyncResult(
|
|
323
|
+
status=SyncStatus.CONFLICT,
|
|
324
|
+
operation="pull",
|
|
325
|
+
timestamp=datetime.now(),
|
|
326
|
+
conflicts=conflicts,
|
|
327
|
+
message=f"Merge conflict in {len(conflicts)} files",
|
|
328
|
+
)
|
|
329
|
+
self.sync_history.append(sync_result)
|
|
330
|
+
return sync_result
|
|
331
|
+
|
|
332
|
+
self.last_pull = datetime.now()
|
|
333
|
+
self.status = SyncStatus.SUCCESS
|
|
334
|
+
|
|
335
|
+
sync_result = SyncResult(
|
|
336
|
+
status=SyncStatus.SUCCESS,
|
|
337
|
+
operation="pull",
|
|
338
|
+
timestamp=self.last_pull,
|
|
339
|
+
conflicts=conflicts,
|
|
340
|
+
message=f"Resolved conflicts in {len(conflicts)} files",
|
|
341
|
+
)
|
|
342
|
+
self.sync_history.append(sync_result)
|
|
343
|
+
return sync_result
|
|
344
|
+
|
|
345
|
+
except Exception as e:
|
|
346
|
+
self.status = SyncStatus.ERROR
|
|
347
|
+
logger.error(f"Pull failed: {e}")
|
|
348
|
+
|
|
349
|
+
sync_result = SyncResult(
|
|
350
|
+
status=SyncStatus.ERROR,
|
|
351
|
+
operation="pull",
|
|
352
|
+
timestamp=datetime.now(),
|
|
353
|
+
message=str(e),
|
|
354
|
+
)
|
|
355
|
+
self.sync_history.append(sync_result)
|
|
356
|
+
return sync_result
|
|
357
|
+
|
|
358
|
+
async def _run_git(self, args: list[str], allow_fail: bool = False) -> str:
|
|
359
|
+
"""
|
|
360
|
+
Run git command asynchronously.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
args: Git command arguments
|
|
364
|
+
allow_fail: Whether to suppress errors
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Command stdout as string
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
RuntimeError: If command fails and allow_fail=False
|
|
371
|
+
"""
|
|
372
|
+
cmd = ["git", "-C", str(self.repo_root)] + args
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
process = await asyncio.create_subprocess_exec(
|
|
376
|
+
*cmd,
|
|
377
|
+
stdout=asyncio.subprocess.PIPE,
|
|
378
|
+
stderr=asyncio.subprocess.PIPE,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
|
|
382
|
+
|
|
383
|
+
if process.returncode != 0 and not allow_fail:
|
|
384
|
+
raise RuntimeError(f"Git command failed: {stderr.decode().strip()}")
|
|
385
|
+
|
|
386
|
+
return stdout.decode().strip()
|
|
387
|
+
|
|
388
|
+
except asyncio.TimeoutError as e:
|
|
389
|
+
if allow_fail:
|
|
390
|
+
return ""
|
|
391
|
+
raise RuntimeError(f"Git command timeout: {' '.join(args)}") from e
|
|
392
|
+
|
|
393
|
+
async def _get_conflicts(self) -> list[str]:
|
|
394
|
+
"""Get list of conflicted files."""
|
|
395
|
+
try:
|
|
396
|
+
result = await self._run_git(["diff", "--name-only", "--diff-filter=U"])
|
|
397
|
+
return result.split("\n") if result else []
|
|
398
|
+
except Exception:
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
async def _resolve_conflicts_auto(self) -> None:
|
|
402
|
+
"""
|
|
403
|
+
Attempt automatic conflict resolution.
|
|
404
|
+
|
|
405
|
+
Strategy:
|
|
406
|
+
- SQLite files: use ours (local)
|
|
407
|
+
- JSONL files: use ours (event log is append-only)
|
|
408
|
+
- Other files: accept git's merge
|
|
409
|
+
"""
|
|
410
|
+
conflicts = await self._get_conflicts()
|
|
411
|
+
|
|
412
|
+
for conflict in conflicts:
|
|
413
|
+
if conflict.endswith(".db"):
|
|
414
|
+
# SQLite: keep local version
|
|
415
|
+
await self._run_git(["checkout", "--ours", conflict])
|
|
416
|
+
elif conflict.endswith(".jsonl"):
|
|
417
|
+
# JSONL: keep local (event log is append-only)
|
|
418
|
+
await self._run_git(["checkout", "--ours", conflict])
|
|
419
|
+
# Text files: accept git's merge (do nothing)
|
|
420
|
+
|
|
421
|
+
await self._run_git(["add", "."])
|
|
422
|
+
|
|
423
|
+
def _get_hostname(self) -> str:
|
|
424
|
+
"""Get hostname for commit messages."""
|
|
425
|
+
return socket.gethostname()
|
|
426
|
+
|
|
427
|
+
def get_sync_history(self, limit: int = 50) -> list[dict[str, Any]]:
|
|
428
|
+
"""
|
|
429
|
+
Get recent sync history.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
limit: Maximum number of results
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
List of sync result dictionaries
|
|
436
|
+
"""
|
|
437
|
+
return [s.to_dict() for s in self.sync_history[-limit:]]
|
|
438
|
+
|
|
439
|
+
def get_status(self) -> dict[str, Any]:
|
|
440
|
+
"""
|
|
441
|
+
Get current sync status.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Status dictionary with sync state and config
|
|
445
|
+
"""
|
|
446
|
+
return {
|
|
447
|
+
"status": self.status.value,
|
|
448
|
+
"last_push": self.last_push.isoformat() if self.last_push else None,
|
|
449
|
+
"last_pull": self.last_pull.isoformat() if self.last_pull else None,
|
|
450
|
+
"config": {
|
|
451
|
+
"push_interval": self.config.push_interval_seconds,
|
|
452
|
+
"pull_interval": self.config.pull_interval_seconds,
|
|
453
|
+
"remote": self.config.remote_name,
|
|
454
|
+
"branch": self.config.branch_name,
|
|
455
|
+
"conflict_strategy": self.config.conflict_strategy.value,
|
|
456
|
+
"sync_path": self.config.sync_path,
|
|
457
|
+
},
|
|
458
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htmlgraph
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0
|
|
4
4
|
Summary: HTML is All You Need - Graph database on web standards
|
|
5
5
|
Project-URL: Homepage, https://github.com/Shakes-tzd/htmlgraph
|
|
6
6
|
Project-URL: Documentation, https://github.com/Shakes-tzd/htmlgraph#readme
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
htmlgraph/__init__.py,sha256=
|
|
1
|
+
htmlgraph/__init__.py,sha256=n4sKlB0iT9Pcx5U77329ssQl39UMvLSskHP8oC_HqtQ,6595
|
|
2
2
|
htmlgraph/__init__.pyi,sha256=8JuFVuDll9jMx9s8ZQHt2tXic-geOJHiXUMB2YjmHhU,6683
|
|
3
3
|
htmlgraph/agent_detection.py,sha256=wEmrDv4hssPX2OkEnJZBHPbalxcaloiJF_hOOow_5WE,3511
|
|
4
4
|
htmlgraph/agent_registry.py,sha256=80TPYr4P0YMizPUbTH4N5wH6D84IKs-HPBLHGeeP6bY,9449
|
|
@@ -7,6 +7,7 @@ htmlgraph/analytics_index.py,sha256=fm0cnZjce2Ii4F9FQo1np_mWsFXuRgTTOzbrCJWGW3g,
|
|
|
7
7
|
htmlgraph/atomic_ops.py,sha256=f0ULZoJThfC-A8xORvRv5OuFuhEnnH7uJrZslnZwmuw,18781
|
|
8
8
|
htmlgraph/attribute_index.py,sha256=oF4TwbFGdtg1_W5AUJ-Ka4VcH-aYz-qldtiPvStJlfA,6594
|
|
9
9
|
htmlgraph/bounded_paths.py,sha256=4HHXiT8q2fhGkrGsGrACFnDrnAzwJ-Tk_4V9h-voenM,18832
|
|
10
|
+
htmlgraph/broadcast_integration.py,sha256=dcWiKDIcpyvNXff56hXUMYp3Z4ECue6-EniiGIwr-XI,7475
|
|
10
11
|
htmlgraph/cli_framework.py,sha256=YfAjTGIECmv_uNpkenYyc9_iKd3jXIG5OstEKpIGWJM,3551
|
|
11
12
|
htmlgraph/config.py,sha256=dhOSfMfZCT-APe_uAvqABWyQ0nEhL6F0NS-15KLPZNg,6888
|
|
12
13
|
htmlgraph/context_analytics.py,sha256=fGuIhGCM-500wH_pTKE24k0Rl01QvQ8Kznoj61KpjiU,11345
|
|
@@ -49,13 +50,15 @@ htmlgraph/pydantic_models.py,sha256=4vGAp2n59Hf8v5mZG-Tx2vOSm3lcE_PypBprcejJUXE,
|
|
|
49
50
|
htmlgraph/quality_gates.py,sha256=ovPrjGgAwK5MjMzEIda4a6M5_S-yGiRJYy8KhzdyTZ0,11686
|
|
50
51
|
htmlgraph/query_builder.py,sha256=GBrjf_6Qoq98JxJr8cYeAMV4QpdmrfFyadJpDjH4clM,18093
|
|
51
52
|
htmlgraph/query_composer.py,sha256=ilasmZBc8_mLS1iq45Kcw2Ig1zCN04-r1HVKr8f9YP0,17332
|
|
53
|
+
htmlgraph/reactive_integration.py,sha256=7Vf7AqPdp-Oe-rzBvwSCV_HWCb5DvaujQRzP4oqd7ag,4113
|
|
52
54
|
htmlgraph/reflection.py,sha256=agNfHsI1ynayTG3kgt5fiQj30LkG9b-v3wtnDsu4-Gk,15663
|
|
53
55
|
htmlgraph/refs.py,sha256=jgsPWRTfPDxhH_dr4EeYdU1ly-e3HpuHgAPnMXMbaF4,10011
|
|
54
56
|
htmlgraph/repo_hash.py,sha256=wYQlgYf0W1LfU95PA6vViA-MlUxA6ry_xDh7kyCB4v4,14888
|
|
55
57
|
htmlgraph/routing.py,sha256=QYDY6bzYPmv6kocAXCqguB1cazN0i_xTo9EVCO3fO2Y,8803
|
|
56
58
|
htmlgraph/server.py,sha256=BZcUM9sfGQzS0PyFOvl5_f4MP9-8C4E8tp77VcanwLM,56211
|
|
59
|
+
htmlgraph/session_context.py,sha256=4XsywYg4-J7ct9B-z3mnbkIUvyD_K389Y73yh5AxBWQ,57007
|
|
57
60
|
htmlgraph/session_hooks.py,sha256=AOjpPMZo2s_d_KXuPiWDo8TIOdkXsEU0CoOHd4G5A10,9195
|
|
58
|
-
htmlgraph/session_manager.py,sha256=
|
|
61
|
+
htmlgraph/session_manager.py,sha256=X-xm9JvfbMt2V2dI9I_lgiosqTV7YnHTLXlwy7F7FZw,101084
|
|
59
62
|
htmlgraph/session_registry.py,sha256=xF6VaFp3tE9LA8PXXnaIs2PKZxMOktxRUJJ7vCs0Mcs,18725
|
|
60
63
|
htmlgraph/session_state.py,sha256=2UcgQNzwBjHkVuGxrAGfHkODopqDexU4wjy9qXKcQIk,15245
|
|
61
64
|
htmlgraph/session_warning.py,sha256=XPT2Cbg6YRNNhw7NpM8aAp5ny-qfCsYY86DURL_eAPc,7878
|
|
@@ -91,9 +94,18 @@ htmlgraph/analytics/strategic/pattern_detector.py,sha256=5SEUpzX5wXsX1tvzkG0oMVG
|
|
|
91
94
|
htmlgraph/analytics/strategic/preference_manager.py,sha256=uO8cX-kaK7EIJh-ovXh5cMc0qkMhUfC6zavvvb7osnc,24776
|
|
92
95
|
htmlgraph/analytics/strategic/suggestion_engine.py,sha256=KV43DF6PETJkmrMA_Ni34heSdQqS_GXKeeGsq5zO6ck,26093
|
|
93
96
|
htmlgraph/api/__init__.py,sha256=PPFJ5QSv-Zei8U0yGOSs8dJKtUQMeloEqsxVBfzNbbA,105
|
|
94
|
-
htmlgraph/api/
|
|
95
|
-
htmlgraph/api/
|
|
96
|
-
htmlgraph/api/
|
|
97
|
+
htmlgraph/api/broadcast.py,sha256=G2vsgbUFOeahy8l1GJwu5X2yBJAID02lgNXxyDMexx0,9051
|
|
98
|
+
htmlgraph/api/broadcast_routes.py,sha256=OJQaUx6_izB_TlyL5N7tzTx3nD9sFs3RUhehlRtl_3k,11376
|
|
99
|
+
htmlgraph/api/broadcast_websocket.py,sha256=ZTHmLsYCExaCkHiDD7LNKQaAjaTQadNoO4OZOrGsU3Q,3822
|
|
100
|
+
htmlgraph/api/cost_alerts_websocket.py,sha256=jmPJ0kO7PKXYe-MwN0rReQ6ACa6E--p9DaMCqDDs3IE,14478
|
|
101
|
+
htmlgraph/api/main.py,sha256=veNvUf4MkyCneV0lQB1R6Zuz_BdGk862rn-lpFQ3fts,101924
|
|
102
|
+
htmlgraph/api/offline.py,sha256=u6RbmeoDPvPsFJYsTNhFZ6yoNE0bgYXFzUi2g2pmeYU,25881
|
|
103
|
+
htmlgraph/api/presence.py,sha256=eF7j5yp7lgO9tF0_VMdE0zeWrUYIAYhgOUHSKe8dr70,14246
|
|
104
|
+
htmlgraph/api/reactive.py,sha256=WW8bBCiu9RBCgNeKE-fohvklJNBnvWj_zdllPkZun2M,14941
|
|
105
|
+
htmlgraph/api/reactive_routes.py,sha256=YdXSpkh8GmmvUhmgXdnJmEJY6Lu0jLTm6QM6BVng2-4,6032
|
|
106
|
+
htmlgraph/api/sync_routes.py,sha256=0ei-TYi0axEAK1aZYbobBIfRsvZ7csiQjJ-zbi-0oQg,4863
|
|
107
|
+
htmlgraph/api/websocket.py,sha256=bO0WS985Ej_27rwoFKy3fFcNLYjV0obvAIju8WzKURY,19915
|
|
108
|
+
htmlgraph/api/static/broadcast-demo.html,sha256=kJ45f9AmHmtCZnf2O3hsfGxa2RAgXLFqxbA689jY8Cg,12611
|
|
97
109
|
htmlgraph/api/static/htmx.min.js,sha256=0VEHzH8ECp6DsbZhdv2SetQLXgJVgToD-Mz-7UbuQrA,48036
|
|
98
110
|
htmlgraph/api/static/style-redesign.css,sha256=zp0ggDJHz81FN5SW5tRcWXPK_jVNCAJyqKhwlZkBfnQ,26759
|
|
99
111
|
htmlgraph/api/static/style.css,sha256=6nUzPvf3Xri0vxbEIunrbJkujP6k3HrM3KC5nrkOjyw,20022
|
|
@@ -163,6 +175,7 @@ htmlgraph/cli/work/snapshot.py,sha256=ZJFF8ea8WlD1EP2iFXfhKK2mfUufWbe_cYvMPcmATo
|
|
|
163
175
|
htmlgraph/cli/work/tracks.py,sha256=MzymITEkbq_jNeCYGLgqWwwa0hAv3UWpPqhEMTHC8zI,16290
|
|
164
176
|
htmlgraph/cli_commands/__init__.py,sha256=GYdoNv80KS2p7bCThuoRbhiLmIZW6ddPqPEFEx567nQ,35
|
|
165
177
|
htmlgraph/cli_commands/feature.py,sha256=KqC0daKE1aMb9-FBHlEEUZbLFvZdjlW5CxqSeO1HVVU,6689
|
|
178
|
+
htmlgraph/cli_commands/sync.py,sha256=3XmqCPgZ11cZ3aAs4523oS54ORlgU24IjioE23XslkQ,5795
|
|
166
179
|
htmlgraph/collections/__init__.py,sha256=bDUTK5grqWKCR5sawI8fa-XoL09p5-heReGdkdSXArY,1163
|
|
167
180
|
htmlgraph/collections/base.py,sha256=Wbpf_5Z340bds4mohzjS_w9fB-qKG01hBa81NkWnDtE,26728
|
|
168
181
|
htmlgraph/collections/bug.py,sha256=uqOWAtJShwZtDYEfsrs6kMnTDUrqhykezMd72_WMaXE,1332
|
|
@@ -183,7 +196,7 @@ htmlgraph/cost_analysis/__init__.py,sha256=kuAA4Fd0gUplOWOiqs3CF4eO1wwekGs_3DaiF
|
|
|
183
196
|
htmlgraph/cost_analysis/analyzer.py,sha256=TDKgRXeOQ-td5LZhkFob4ajEf-JssSCGKlMS3GysYJU,15040
|
|
184
197
|
htmlgraph/db/__init__.py,sha256=1yWTyWrN2kcmW6mVKCUf4POSUXTOSaAljwEyyC0Xs1w,1033
|
|
185
198
|
htmlgraph/db/queries.py,sha256=FtqOIBJC0EociEYNJr6gTVROqd9m5yZdVGYLxXNVSdk,22608
|
|
186
|
-
htmlgraph/db/schema.py,sha256=
|
|
199
|
+
htmlgraph/db/schema.py,sha256=HLfUqtRC9QoU3mwPRWU3KugCMAdvwns9_Iu0BWSVO3s,78559
|
|
187
200
|
htmlgraph/docs/API_REFERENCE.md,sha256=LYsYeHinF6GEsjMvrgdQSbgmMPsOh4ZQ1WK11niqNvo,17862
|
|
188
201
|
htmlgraph/docs/HTTP_API.md,sha256=IrX-wZckFn-K3ztCVcT-zyGqJL2TtY1qYV7dUuC7kzc,13344
|
|
189
202
|
htmlgraph/docs/INTEGRATION_GUIDE.md,sha256=uNNM0ipY0bFAkZaL1CkvnA_Wt2MVPGRBdA4927cZaHo,16910
|
|
@@ -213,7 +226,7 @@ htmlgraph/hooks/cigs_pretool_enforcer.py,sha256=LC5y5IrKdTx2aq5ht5U7Tceq0k63-7EI
|
|
|
213
226
|
htmlgraph/hooks/concurrent_sessions.py,sha256=qOiwDfynphVG0-2pVBakEzOwMORU8ebN1gMjcN4S0z0,6476
|
|
214
227
|
htmlgraph/hooks/context.py,sha256=tJ4dIL8uTFHyqyuuMc-ETDuOikeD5cN3Mdjmfg6W0HE,13108
|
|
215
228
|
htmlgraph/hooks/drift_handler.py,sha256=UchkeVmjgI6J4NE4vKNTHsY6ZorvUHvp1FOfwTEY-Cs,17626
|
|
216
|
-
htmlgraph/hooks/event_tracker.py,sha256=
|
|
229
|
+
htmlgraph/hooks/event_tracker.py,sha256=yFDGVsRTvVkmEhVPkZGwEz66ymIWZh9hDbuYMEvUkLM,53442
|
|
217
230
|
htmlgraph/hooks/git_commands.py,sha256=NPzthfzGJ_bkDi7soehHOxI9FLL-6BL8Tie9Byb_zf4,4803
|
|
218
231
|
htmlgraph/hooks/hooks-config.example.json,sha256=tXpk-U-FZzGOoNJK2uiDMbIHCYEHA794J-El0fBwkqg,197
|
|
219
232
|
htmlgraph/hooks/installer.py,sha256=NdZHOER7DHFyZYXiZIS-CgLt1ZUxJ4O4ju8HcPjJajs,11816
|
|
@@ -322,16 +335,18 @@ htmlgraph/services/__init__.py,sha256=Cy-4RI7raV4jd2Vfc5Zya0VTgV0x9LNt32h9DhYVGk
|
|
|
322
335
|
htmlgraph/services/claiming.py,sha256=HcrltEJKN72mxuD7fGuXWeh1U0vwhjMvhZcFc02EiyU,6071
|
|
323
336
|
htmlgraph/sessions/__init__.py,sha256=58XJkCNtksEalKFhQsovzyewQlI6hrPKXzQOuEuMDy4,429
|
|
324
337
|
htmlgraph/sessions/handoff.py,sha256=YqSVtVwQp99Zcnq5jlbsDaWmTKv8NpLPdouXVJ96RaI,21880
|
|
338
|
+
htmlgraph/sync/__init__.py,sha256=j6zkmrGGi5LRFMsNntZ6_E8d_45OGRu2J8R6KujXFl4,360
|
|
339
|
+
htmlgraph/sync/git_sync.py,sha256=n1ItI0hEFPXrfOyryTfcIJfZcRLeVtMrhs4RFrtII_c,15734
|
|
325
340
|
htmlgraph/templates/AGENTS.md.template,sha256=f96h7V6ygwj-v-fanVI48eYMxR6t_se4bet1H4ZsDpI,7642
|
|
326
341
|
htmlgraph/templates/CLAUDE.md.template,sha256=h1kG2hTX2XYig2KszsHBfzrwa_4Cfcq2Pj4SwqzeDlM,1984
|
|
327
342
|
htmlgraph/templates/GEMINI.md.template,sha256=gAGzE53Avki87BM_otqy5HdcYCoLsHgqaKjVzNzPMX8,1622
|
|
328
343
|
htmlgraph/templates/orchestration-view.html,sha256=DlS7LlcjH0oO_KYILjuF1X42t8QhKLH4F85rkO54alY,10472
|
|
329
|
-
htmlgraph-0.
|
|
330
|
-
htmlgraph-0.
|
|
331
|
-
htmlgraph-0.
|
|
332
|
-
htmlgraph-0.
|
|
333
|
-
htmlgraph-0.
|
|
334
|
-
htmlgraph-0.
|
|
335
|
-
htmlgraph-0.
|
|
336
|
-
htmlgraph-0.
|
|
337
|
-
htmlgraph-0.
|
|
344
|
+
htmlgraph-0.28.0.data/data/htmlgraph/dashboard.html,sha256=MUT6SaYnazoyDcvHz5hN1omYswyIoUfeoZLf2M_iblo,251268
|
|
345
|
+
htmlgraph-0.28.0.data/data/htmlgraph/styles.css,sha256=oDUSC8jG-V-hKojOBO9J88hxAeY2wJrBYTq0uCwX_Y4,7135
|
|
346
|
+
htmlgraph-0.28.0.data/data/htmlgraph/templates/AGENTS.md.template,sha256=f96h7V6ygwj-v-fanVI48eYMxR6t_se4bet1H4ZsDpI,7642
|
|
347
|
+
htmlgraph-0.28.0.data/data/htmlgraph/templates/CLAUDE.md.template,sha256=h1kG2hTX2XYig2KszsHBfzrwa_4Cfcq2Pj4SwqzeDlM,1984
|
|
348
|
+
htmlgraph-0.28.0.data/data/htmlgraph/templates/GEMINI.md.template,sha256=gAGzE53Avki87BM_otqy5HdcYCoLsHgqaKjVzNzPMX8,1622
|
|
349
|
+
htmlgraph-0.28.0.dist-info/METADATA,sha256=XE3zRpXFYTI3tn0e2C_-Ipsi29pWYwXzcfasXfS24is,10220
|
|
350
|
+
htmlgraph-0.28.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
351
|
+
htmlgraph-0.28.0.dist-info/entry_points.txt,sha256=Wmdo5cx8pt6NoMsssVE2mZH1CZLSUsrg_3iSWatiyn0,103
|
|
352
|
+
htmlgraph-0.28.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|