aline-ai 0.6.6__py3-none-any.whl → 0.7.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.
- {aline_ai-0.6.6.dist-info → aline_ai-0.7.0.dist-info}/METADATA +1 -1
- {aline_ai-0.6.6.dist-info → aline_ai-0.7.0.dist-info}/RECORD +28 -28
- realign/__init__.py +1 -1
- realign/agent_names.py +2 -2
- realign/claude_hooks/terminal_state.py +32 -1
- realign/cli.py +2 -4
- realign/codex_detector.py +17 -2
- realign/codex_home.py +24 -6
- realign/commands/auth.py +20 -0
- realign/commands/doctor.py +74 -1
- realign/commands/export_shares.py +151 -0
- realign/commands/import_shares.py +203 -1
- realign/commands/sync_agent.py +347 -0
- realign/dashboard/app.py +3 -53
- realign/dashboard/screens/create_agent_info.py +131 -20
- realign/dashboard/styles/dashboard.tcss +0 -73
- realign/dashboard/tmux_manager.py +36 -10
- realign/dashboard/widgets/__init__.py +0 -6
- realign/dashboard/widgets/agents_panel.py +157 -24
- realign/db/base.py +43 -1
- realign/db/schema.py +60 -2
- realign/db/sqlite_db.py +176 -1
- realign/watcher_core.py +133 -2
- realign/worker_core.py +37 -2
- realign/dashboard/widgets/terminal_panel.py +0 -1688
- {aline_ai-0.6.6.dist-info → aline_ai-0.7.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.6.dist-info → aline_ai-0.7.0.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.6.dist-info → aline_ai-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.6.6.dist-info → aline_ai-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -44,6 +44,206 @@ else:
|
|
|
44
44
|
console = None
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def download_share_data(
|
|
48
|
+
share_url: str,
|
|
49
|
+
password: Optional[str] = None,
|
|
50
|
+
) -> Dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Download share data from a share URL.
|
|
53
|
+
|
|
54
|
+
Extracts download logic (URL parse, auth, fetch) into a reusable function.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
share_url: Full share URL (e.g., https://realign-server.vercel.app/share/abc123)
|
|
58
|
+
password: Password for encrypted shares
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
{"success": True, "data": conversation_data} on success
|
|
62
|
+
{"success": False, "error": str} on failure
|
|
63
|
+
"""
|
|
64
|
+
if not HTTPX_AVAILABLE:
|
|
65
|
+
return {"success": False, "error": "httpx package not installed. Install with: pip install httpx"}
|
|
66
|
+
|
|
67
|
+
share_id = extract_share_id(share_url)
|
|
68
|
+
if not share_id:
|
|
69
|
+
return {"success": False, "error": f"Invalid share URL format: {share_url}"}
|
|
70
|
+
|
|
71
|
+
logger.info(f"download_share_data: share_id={share_id}")
|
|
72
|
+
|
|
73
|
+
config = ReAlignConfig.load()
|
|
74
|
+
backend_url = config.share_backend_url or "https://realign-server.vercel.app"
|
|
75
|
+
|
|
76
|
+
# Get share info
|
|
77
|
+
try:
|
|
78
|
+
info_response = httpx.get(f"{backend_url}/api/share/{share_id}/info", timeout=10.0)
|
|
79
|
+
info_response.raise_for_status()
|
|
80
|
+
info = info_response.json()
|
|
81
|
+
except Exception as e:
|
|
82
|
+
return {"success": False, "error": f"Failed to fetch share info: {e}"}
|
|
83
|
+
|
|
84
|
+
# Authenticate
|
|
85
|
+
if info.get("requires_password"):
|
|
86
|
+
if not password:
|
|
87
|
+
return {"success": False, "error": "This share requires a password"}
|
|
88
|
+
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
|
89
|
+
headers = {"X-Password-Hash": password_hash}
|
|
90
|
+
else:
|
|
91
|
+
try:
|
|
92
|
+
session_response = httpx.post(
|
|
93
|
+
f"{backend_url}/api/share/{share_id}/session", timeout=10.0
|
|
94
|
+
)
|
|
95
|
+
session_response.raise_for_status()
|
|
96
|
+
session_data = session_response.json()
|
|
97
|
+
session_token = session_data.get("session_token")
|
|
98
|
+
headers = {"Authorization": f"Bearer {session_token}"}
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return {"success": False, "error": f"Failed to create session: {e}"}
|
|
101
|
+
|
|
102
|
+
# Download export data
|
|
103
|
+
try:
|
|
104
|
+
export_response = httpx.get(
|
|
105
|
+
f"{backend_url}/api/share/{share_id}/export", headers=headers, timeout=30.0
|
|
106
|
+
)
|
|
107
|
+
export_data = export_response.json()
|
|
108
|
+
|
|
109
|
+
if export_response.status_code == 413 or export_data.get("needs_chunked_download"):
|
|
110
|
+
total_chunks = export_data.get("total_chunks", 1)
|
|
111
|
+
raw_data = _download_chunks(backend_url, share_id, headers, total_chunks)
|
|
112
|
+
conversation_data = json.loads(raw_data)
|
|
113
|
+
export_data = {
|
|
114
|
+
"success": True,
|
|
115
|
+
"data": conversation_data,
|
|
116
|
+
"metadata": export_data.get("metadata", {}),
|
|
117
|
+
}
|
|
118
|
+
else:
|
|
119
|
+
export_response.raise_for_status()
|
|
120
|
+
except Exception as e:
|
|
121
|
+
return {"success": False, "error": f"Failed to download data: {e}"}
|
|
122
|
+
|
|
123
|
+
if not export_data.get("success"):
|
|
124
|
+
return {"success": False, "error": export_data.get("error", "Unknown error")}
|
|
125
|
+
|
|
126
|
+
return {"success": True, "data": export_data["data"]}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def import_agent_from_share(
|
|
130
|
+
share_url: str,
|
|
131
|
+
password: Optional[str] = None,
|
|
132
|
+
db: Optional[DatabaseInterface] = None,
|
|
133
|
+
) -> Dict[str, Any]:
|
|
134
|
+
"""
|
|
135
|
+
Import an agent from a share link.
|
|
136
|
+
|
|
137
|
+
Downloads share data, creates agent_info record, imports sessions with
|
|
138
|
+
created_by/shared_by tracking, and links them to the agent.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
share_url: Full share URL
|
|
142
|
+
password: Password for encrypted shares
|
|
143
|
+
db: Database instance (auto-created if None)
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
{"success": True, "agent_id", "agent_name", "agent_description",
|
|
147
|
+
"sessions_imported", "turns_imported"} on success
|
|
148
|
+
{"success": False, "error": str} on failure
|
|
149
|
+
"""
|
|
150
|
+
os.environ["REALIGN_DISABLE_AUTO_SUMMARIES"] = "1"
|
|
151
|
+
|
|
152
|
+
result = download_share_data(share_url, password)
|
|
153
|
+
if not result["success"]:
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
conversation_data = result["data"]
|
|
157
|
+
|
|
158
|
+
# Extract agent identity from share data
|
|
159
|
+
event_data = conversation_data.get("event", {})
|
|
160
|
+
event_id = event_data.get("event_id", "")
|
|
161
|
+
|
|
162
|
+
# Agent ID: strip "agent-" prefix if present, otherwise generate new UUID
|
|
163
|
+
if event_id.startswith("agent-"):
|
|
164
|
+
agent_id = event_id[6:]
|
|
165
|
+
else:
|
|
166
|
+
agent_id = str(uuid_lib.uuid4())
|
|
167
|
+
|
|
168
|
+
agent_name = event_data.get("title") or "Imported Agent"
|
|
169
|
+
agent_description = event_data.get("description") or ""
|
|
170
|
+
|
|
171
|
+
# Set up database
|
|
172
|
+
if db is None:
|
|
173
|
+
from ..db.sqlite_db import SQLiteDatabase
|
|
174
|
+
|
|
175
|
+
config = ReAlignConfig.load()
|
|
176
|
+
db_path = Path(config.sqlite_db_path).expanduser()
|
|
177
|
+
db = SQLiteDatabase(db_path=db_path)
|
|
178
|
+
|
|
179
|
+
# Create agent_info record
|
|
180
|
+
try:
|
|
181
|
+
db.get_or_create_agent_info(agent_id, name=agent_name)
|
|
182
|
+
if agent_description:
|
|
183
|
+
db.update_agent_info(agent_id, description=agent_description)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
return {"success": False, "error": f"Failed to create agent info: {e}"}
|
|
186
|
+
|
|
187
|
+
# Import sessions and link to agent
|
|
188
|
+
sessions_data = conversation_data.get("sessions", [])
|
|
189
|
+
total_sessions = 0
|
|
190
|
+
total_turns = 0
|
|
191
|
+
|
|
192
|
+
for session_data in sessions_data:
|
|
193
|
+
session_id = session_data.get("session_id") or generate_uuid()
|
|
194
|
+
|
|
195
|
+
# Use existing import logic (handles created_by/shared_by)
|
|
196
|
+
try:
|
|
197
|
+
# Use a dummy event_id — we don't need event linkage for agent imports
|
|
198
|
+
import_result = import_session_with_turns(
|
|
199
|
+
session_data, event_id or agent_id, share_url, db, force=False
|
|
200
|
+
)
|
|
201
|
+
total_sessions += import_result["sessions"]
|
|
202
|
+
total_turns += import_result["turns"]
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error(f"Failed to import session {session_id}: {e}")
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# Link session to agent
|
|
208
|
+
try:
|
|
209
|
+
db.update_session_agent_id(session_id, agent_id)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error(f"Failed to link session {session_id} to agent: {e}")
|
|
212
|
+
|
|
213
|
+
# Extract and store sync metadata (for unencrypted shares)
|
|
214
|
+
sync_meta = conversation_data.get("sync_metadata", {})
|
|
215
|
+
contributor_token = sync_meta.get("contributor_token")
|
|
216
|
+
sync_enabled = False
|
|
217
|
+
|
|
218
|
+
if contributor_token and not password:
|
|
219
|
+
sync_enabled = True
|
|
220
|
+
share_id = extract_share_id(share_url)
|
|
221
|
+
try:
|
|
222
|
+
db.update_agent_sync_metadata(
|
|
223
|
+
agent_id,
|
|
224
|
+
share_id=share_id,
|
|
225
|
+
share_url=share_url,
|
|
226
|
+
share_contributor_token=contributor_token,
|
|
227
|
+
# No admin_token for importers
|
|
228
|
+
last_synced_at=datetime.now().isoformat(),
|
|
229
|
+
sync_version=sync_meta.get("sync_version", 0),
|
|
230
|
+
)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.warning(f"Failed to store sync metadata: {e}")
|
|
233
|
+
sync_enabled = False
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"success": True,
|
|
237
|
+
"agent_id": agent_id,
|
|
238
|
+
"agent_name": agent_name,
|
|
239
|
+
"agent_description": agent_description,
|
|
240
|
+
"sessions_imported": total_sessions,
|
|
241
|
+
"turns_imported": total_turns,
|
|
242
|
+
"sync_enabled": sync_enabled,
|
|
243
|
+
"share_url": share_url if sync_enabled else None,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
47
247
|
def import_share_command(
|
|
48
248
|
share_url: str,
|
|
49
249
|
password: Optional[str] = None,
|
|
@@ -84,11 +284,13 @@ def import_share_command(
|
|
|
84
284
|
|
|
85
285
|
logger.info(f"Extracted share_id: {share_id}")
|
|
86
286
|
|
|
87
|
-
#
|
|
287
|
+
# Use download_share_data helper for non-interactive password case
|
|
288
|
+
# For interactive mode, we still need the prompt flow
|
|
88
289
|
config = ReAlignConfig.load()
|
|
89
290
|
backend_url = config.share_backend_url or "https://realign-server.vercel.app"
|
|
90
291
|
logger.info(f"Backend URL: {backend_url}")
|
|
91
292
|
|
|
293
|
+
# 2. Get share info and handle password prompt interactively
|
|
92
294
|
try:
|
|
93
295
|
if console:
|
|
94
296
|
console.print(f"[cyan]Fetching share info from {backend_url}...[/cyan]")
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Sync agent command - Bidirectional sync for shared agents.
|
|
4
|
+
|
|
5
|
+
Pull remote sessions, merge locally (union of sessions, dedup by content_hash),
|
|
6
|
+
push merged result back. Uses optimistic locking via sync_version.
|
|
7
|
+
|
|
8
|
+
Sync works with unencrypted shares only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import logging
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Optional, Dict, Any, Callable
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
HTTPX_AVAILABLE = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
HTTPX_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
from ..logging_config import setup_logger
|
|
25
|
+
|
|
26
|
+
logger = setup_logger("realign.commands.sync_agent", "sync_agent.log")
|
|
27
|
+
|
|
28
|
+
MAX_SYNC_RETRIES = 3
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sync_agent_command(
|
|
32
|
+
agent_id: str,
|
|
33
|
+
backend_url: Optional[str] = None,
|
|
34
|
+
progress_callback: Optional[Callable[[str], None]] = None,
|
|
35
|
+
) -> dict:
|
|
36
|
+
"""
|
|
37
|
+
Sync an agent's sessions with the remote share.
|
|
38
|
+
|
|
39
|
+
Algorithm:
|
|
40
|
+
1. Load local state (agent_info, sessions, content hashes)
|
|
41
|
+
2. Pull remote state (full download via export endpoint)
|
|
42
|
+
3. Merge: union of sessions deduped by content_hash, last-write-wins for name/desc
|
|
43
|
+
4. Push merged state via PUT with optimistic locking
|
|
44
|
+
5. Update local sync metadata
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
agent_id: The agent_info ID to sync
|
|
48
|
+
backend_url: Backend server URL (uses config default if None)
|
|
49
|
+
progress_callback: Optional callback for progress updates
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
{"success": True, "sessions_pulled": N, "sessions_pushed": N, ...} on success
|
|
53
|
+
{"success": False, "error": str} on failure
|
|
54
|
+
"""
|
|
55
|
+
def _progress(msg: str) -> None:
|
|
56
|
+
if progress_callback:
|
|
57
|
+
progress_callback(msg)
|
|
58
|
+
|
|
59
|
+
if not HTTPX_AVAILABLE:
|
|
60
|
+
return {"success": False, "error": "httpx package not installed"}
|
|
61
|
+
|
|
62
|
+
# Get backend URL
|
|
63
|
+
if backend_url is None:
|
|
64
|
+
from ..config import ReAlignConfig
|
|
65
|
+
|
|
66
|
+
config = ReAlignConfig.load()
|
|
67
|
+
backend_url = config.share_backend_url or "https://realign-server.vercel.app"
|
|
68
|
+
|
|
69
|
+
# Get database
|
|
70
|
+
from ..db import get_database
|
|
71
|
+
|
|
72
|
+
db = get_database()
|
|
73
|
+
|
|
74
|
+
# 1. Load local state
|
|
75
|
+
_progress("Loading local agent data...")
|
|
76
|
+
|
|
77
|
+
agent_info = db.get_agent_info(agent_id)
|
|
78
|
+
if not agent_info:
|
|
79
|
+
return {"success": False, "error": f"Agent not found: {agent_id}"}
|
|
80
|
+
|
|
81
|
+
if not agent_info.share_id or not agent_info.share_url:
|
|
82
|
+
return {"success": False, "error": "Agent has no share metadata (not shared yet)"}
|
|
83
|
+
|
|
84
|
+
token = agent_info.share_admin_token or agent_info.share_contributor_token
|
|
85
|
+
if not token:
|
|
86
|
+
return {"success": False, "error": "No token available for sync (need admin or contributor token)"}
|
|
87
|
+
|
|
88
|
+
share_id = agent_info.share_id
|
|
89
|
+
local_sync_version = agent_info.sync_version or 0
|
|
90
|
+
|
|
91
|
+
local_sessions = db.get_sessions_by_agent_id(agent_id)
|
|
92
|
+
local_content_hashes = db.get_agent_content_hashes(agent_id)
|
|
93
|
+
|
|
94
|
+
logger.info(
|
|
95
|
+
f"Sync: agent={agent_id}, share={share_id}, "
|
|
96
|
+
f"local_sessions={len(local_sessions)}, local_hashes={len(local_content_hashes)}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# 2. Pull remote state
|
|
100
|
+
_progress("Pulling remote data...")
|
|
101
|
+
|
|
102
|
+
remote_data = _pull_remote(backend_url, share_id)
|
|
103
|
+
if not remote_data.get("success"):
|
|
104
|
+
return {"success": False, "error": f"Failed to pull remote: {remote_data.get('error')}"}
|
|
105
|
+
|
|
106
|
+
conversation_data = remote_data["data"]
|
|
107
|
+
remote_sync_meta = conversation_data.get("sync_metadata", {})
|
|
108
|
+
remote_sync_version = remote_sync_meta.get("sync_version", 0)
|
|
109
|
+
|
|
110
|
+
remote_sessions_data = conversation_data.get("sessions", [])
|
|
111
|
+
remote_event = conversation_data.get("event", {})
|
|
112
|
+
|
|
113
|
+
# 3. Merge
|
|
114
|
+
_progress("Merging sessions...")
|
|
115
|
+
|
|
116
|
+
# Collect remote content hashes
|
|
117
|
+
remote_content_hashes = set()
|
|
118
|
+
for session_data in remote_sessions_data:
|
|
119
|
+
for turn_data in session_data.get("turns", []):
|
|
120
|
+
h = turn_data.get("content_hash")
|
|
121
|
+
if h:
|
|
122
|
+
remote_content_hashes.add(h)
|
|
123
|
+
|
|
124
|
+
# Import new remote sessions/turns locally
|
|
125
|
+
sessions_pulled = 0
|
|
126
|
+
from .import_shares import import_session_with_turns
|
|
127
|
+
|
|
128
|
+
for session_data in remote_sessions_data:
|
|
129
|
+
session_id = session_data.get("session_id", "")
|
|
130
|
+
session_turns = session_data.get("turns", [])
|
|
131
|
+
|
|
132
|
+
# Check if any turns in this session are new to us
|
|
133
|
+
new_turns = [
|
|
134
|
+
t for t in session_turns
|
|
135
|
+
if t.get("content_hash") and t["content_hash"] not in local_content_hashes
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
if not new_turns:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# Import the session (import_session_with_turns handles dedup by content_hash)
|
|
142
|
+
try:
|
|
143
|
+
# Suppress auto-summaries during sync
|
|
144
|
+
os.environ["REALIGN_DISABLE_AUTO_SUMMARIES"] = "1"
|
|
145
|
+
import_result = import_session_with_turns(
|
|
146
|
+
session_data, f"agent-{agent_id}", agent_info.share_url, db, force=False
|
|
147
|
+
)
|
|
148
|
+
if import_result.get("sessions", 0) > 0 or import_result.get("turns", 0) > 0:
|
|
149
|
+
sessions_pulled += 1
|
|
150
|
+
|
|
151
|
+
# Link session to agent
|
|
152
|
+
db.update_session_agent_id(session_id, agent_id)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.error(f"Failed to import remote session {session_id}: {e}")
|
|
155
|
+
|
|
156
|
+
# Merge name/description: last-write-wins by updated_at
|
|
157
|
+
description_updated = False
|
|
158
|
+
remote_updated_at = remote_event.get("updated_at")
|
|
159
|
+
if remote_updated_at:
|
|
160
|
+
try:
|
|
161
|
+
remote_dt = datetime.fromisoformat(remote_updated_at.replace("Z", "+00:00"))
|
|
162
|
+
local_dt = agent_info.updated_at
|
|
163
|
+
if hasattr(local_dt, "tzinfo") and local_dt.tzinfo is None:
|
|
164
|
+
local_dt = local_dt.replace(tzinfo=timezone.utc)
|
|
165
|
+
if remote_dt > local_dt:
|
|
166
|
+
remote_name = remote_event.get("title")
|
|
167
|
+
remote_desc = remote_event.get("description")
|
|
168
|
+
updates = {}
|
|
169
|
+
if remote_name and remote_name != agent_info.name:
|
|
170
|
+
updates["name"] = remote_name
|
|
171
|
+
if remote_desc is not None and remote_desc != agent_info.description:
|
|
172
|
+
updates["description"] = remote_desc
|
|
173
|
+
if updates:
|
|
174
|
+
db.update_agent_info(agent_id, **updates)
|
|
175
|
+
description_updated = True
|
|
176
|
+
agent_info = db.get_agent_info(agent_id)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.warning(f"Failed to compare timestamps for name/desc merge: {e}")
|
|
179
|
+
|
|
180
|
+
# 4. Build merged data and push
|
|
181
|
+
_progress("Pushing merged data...")
|
|
182
|
+
|
|
183
|
+
# Reload local state after merge
|
|
184
|
+
local_sessions = db.get_sessions_by_agent_id(agent_id)
|
|
185
|
+
local_content_hashes = db.get_agent_content_hashes(agent_id)
|
|
186
|
+
|
|
187
|
+
# Count sessions pushed (local sessions with turns not in remote)
|
|
188
|
+
sessions_pushed = 0
|
|
189
|
+
for session in local_sessions:
|
|
190
|
+
turns = db.get_turns_for_session(session.id)
|
|
191
|
+
new_local_turns = [t for t in turns if t.content_hash not in remote_content_hashes]
|
|
192
|
+
if new_local_turns:
|
|
193
|
+
sessions_pushed += 1
|
|
194
|
+
|
|
195
|
+
# Build full conversation data for push
|
|
196
|
+
merged_conversation = _build_merged_conversation_data(
|
|
197
|
+
agent_info=agent_info,
|
|
198
|
+
agent_id=agent_id,
|
|
199
|
+
sessions=local_sessions,
|
|
200
|
+
db=db,
|
|
201
|
+
contributor_token=agent_info.share_contributor_token,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Push with optimistic locking + retry
|
|
205
|
+
from .export_shares import _update_share_content
|
|
206
|
+
|
|
207
|
+
new_version = remote_sync_version
|
|
208
|
+
for attempt in range(MAX_SYNC_RETRIES):
|
|
209
|
+
try:
|
|
210
|
+
push_result = _update_share_content(
|
|
211
|
+
backend_url=backend_url,
|
|
212
|
+
share_id=share_id,
|
|
213
|
+
token=token,
|
|
214
|
+
conversation_data=merged_conversation,
|
|
215
|
+
expected_version=new_version,
|
|
216
|
+
)
|
|
217
|
+
new_version = push_result.get("version", new_version + 1)
|
|
218
|
+
break
|
|
219
|
+
except Exception as e:
|
|
220
|
+
error_str = str(e)
|
|
221
|
+
if "409" in error_str and attempt < MAX_SYNC_RETRIES - 1:
|
|
222
|
+
_progress(f"Version conflict, retrying ({attempt + 2}/{MAX_SYNC_RETRIES})...")
|
|
223
|
+
# Re-pull and retry
|
|
224
|
+
remote_data = _pull_remote(backend_url, share_id)
|
|
225
|
+
if remote_data.get("success"):
|
|
226
|
+
conv = remote_data["data"]
|
|
227
|
+
new_version = conv.get("sync_metadata", {}).get("sync_version", 0)
|
|
228
|
+
continue
|
|
229
|
+
else:
|
|
230
|
+
logger.error(f"Push failed after {attempt + 1} attempts: {e}")
|
|
231
|
+
return {"success": False, "error": f"Push failed: {e}"}
|
|
232
|
+
|
|
233
|
+
# 5. Update local sync metadata
|
|
234
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
235
|
+
db.update_agent_sync_metadata(
|
|
236
|
+
agent_id,
|
|
237
|
+
last_synced_at=now_iso,
|
|
238
|
+
sync_version=new_version,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
_progress("Sync complete!")
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"success": True,
|
|
245
|
+
"sessions_pulled": sessions_pulled,
|
|
246
|
+
"sessions_pushed": sessions_pushed,
|
|
247
|
+
"description_updated": description_updated,
|
|
248
|
+
"new_sync_version": new_version,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _pull_remote(backend_url: str, share_id: str) -> dict:
|
|
253
|
+
"""Pull remote share data via the download_share_data helper."""
|
|
254
|
+
try:
|
|
255
|
+
from .import_shares import download_share_data
|
|
256
|
+
|
|
257
|
+
share_url = f"{backend_url}/share/{share_id}"
|
|
258
|
+
return download_share_data(share_url, password=None)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
return {"success": False, "error": str(e)}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _build_merged_conversation_data(
|
|
264
|
+
agent_info,
|
|
265
|
+
agent_id: str,
|
|
266
|
+
sessions,
|
|
267
|
+
db,
|
|
268
|
+
contributor_token: Optional[str] = None,
|
|
269
|
+
) -> dict:
|
|
270
|
+
"""
|
|
271
|
+
Build a full conversation data dict from local agent state.
|
|
272
|
+
|
|
273
|
+
Mirrors the structure of build_enhanced_conversation_data but works
|
|
274
|
+
directly from DB records without ExportableSession wrappers.
|
|
275
|
+
"""
|
|
276
|
+
import json as json_module
|
|
277
|
+
|
|
278
|
+
event_data = {
|
|
279
|
+
"event_id": f"agent-{agent_id}",
|
|
280
|
+
"title": agent_info.name or "Agent Sessions",
|
|
281
|
+
"description": agent_info.description or "",
|
|
282
|
+
"event_type": "agent",
|
|
283
|
+
"status": "active",
|
|
284
|
+
"created_at": agent_info.created_at.isoformat() if agent_info.created_at else None,
|
|
285
|
+
"updated_at": agent_info.updated_at.isoformat() if agent_info.updated_at else None,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
sessions_data = []
|
|
289
|
+
for session in sessions:
|
|
290
|
+
turns = db.get_turns_for_session(session.id)
|
|
291
|
+
turns_data = []
|
|
292
|
+
for turn in turns:
|
|
293
|
+
turn_content = db.get_turn_content(turn.id)
|
|
294
|
+
messages = []
|
|
295
|
+
if turn_content:
|
|
296
|
+
for line in turn_content.strip().split("\n"):
|
|
297
|
+
if line.strip():
|
|
298
|
+
try:
|
|
299
|
+
messages.append(json_module.loads(line))
|
|
300
|
+
except Exception:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
turns_data.append({
|
|
304
|
+
"turn_id": turn.id,
|
|
305
|
+
"turn_number": turn.turn_number,
|
|
306
|
+
"content_hash": turn.content_hash,
|
|
307
|
+
"timestamp": turn.timestamp.isoformat() if turn.timestamp else None,
|
|
308
|
+
"llm_title": turn.llm_title or "",
|
|
309
|
+
"llm_description": turn.llm_description,
|
|
310
|
+
"user_message": turn.user_message,
|
|
311
|
+
"assistant_summary": turn.assistant_summary,
|
|
312
|
+
"model_name": turn.model_name,
|
|
313
|
+
"git_commit_hash": turn.git_commit_hash,
|
|
314
|
+
"messages": messages,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
sessions_data.append({
|
|
318
|
+
"session_id": session.id,
|
|
319
|
+
"session_type": session.session_type or "unknown",
|
|
320
|
+
"workspace_path": session.workspace_path,
|
|
321
|
+
"session_title": session.session_title,
|
|
322
|
+
"session_summary": session.session_summary,
|
|
323
|
+
"started_at": session.started_at.isoformat() if session.started_at else None,
|
|
324
|
+
"last_activity_at": session.last_activity_at.isoformat() if session.last_activity_at else None,
|
|
325
|
+
"created_by": session.created_by,
|
|
326
|
+
"shared_by": session.shared_by,
|
|
327
|
+
"turns": turns_data,
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
username = os.environ.get("USER") or os.environ.get("USERNAME") or "anonymous"
|
|
331
|
+
|
|
332
|
+
result = {
|
|
333
|
+
"version": "2.1",
|
|
334
|
+
"username": username,
|
|
335
|
+
"time": datetime.now(timezone.utc).isoformat(),
|
|
336
|
+
"event": event_data,
|
|
337
|
+
"sessions": sessions_data,
|
|
338
|
+
"ui_metadata": {},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if contributor_token:
|
|
342
|
+
result["sync_metadata"] = {
|
|
343
|
+
"contributor_token": contributor_token,
|
|
344
|
+
"sync_version": agent_info.sync_version or 0,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return result
|
realign/dashboard/app.py
CHANGED
|
@@ -13,8 +13,6 @@ from textual.widgets import Footer, TabbedContent, TabPane
|
|
|
13
13
|
from ..logging_config import setup_logger
|
|
14
14
|
from .widgets import (
|
|
15
15
|
AlineHeader,
|
|
16
|
-
WatcherPanel,
|
|
17
|
-
WorkerPanel,
|
|
18
16
|
ConfigPanel,
|
|
19
17
|
AgentsPanel,
|
|
20
18
|
)
|
|
@@ -64,30 +62,24 @@ class AlineDashboard(App):
|
|
|
64
62
|
Binding("?", "help", "Help"),
|
|
65
63
|
Binding("tab", "next_tab", "Next Tab", priority=True, show=False),
|
|
66
64
|
Binding("shift+tab", "prev_tab", "Prev Tab", priority=True, show=False),
|
|
67
|
-
Binding("n", "page_next", "Next Page", show=False),
|
|
68
|
-
Binding("p", "page_prev", "Prev Page", show=False),
|
|
69
|
-
Binding("s", "switch_view", "Switch View", show=False),
|
|
70
65
|
Binding("ctrl+c", "quit_confirm", "Quit", priority=True),
|
|
71
66
|
]
|
|
72
67
|
|
|
73
68
|
_quit_confirm_window_s: float = 1.2
|
|
74
69
|
|
|
75
|
-
def __init__(self,
|
|
70
|
+
def __init__(self, use_native_terminal: bool | None = None):
|
|
76
71
|
"""Initialize the dashboard.
|
|
77
72
|
|
|
78
73
|
Args:
|
|
79
|
-
dev_mode: If True, shows developer tabs (Watcher, Worker).
|
|
80
74
|
use_native_terminal: If True, use native terminal backend (iTerm2/Kitty).
|
|
81
75
|
If False, use tmux.
|
|
82
76
|
If None (default), auto-detect from ALINE_TERMINAL_MODE env var.
|
|
83
77
|
"""
|
|
84
78
|
super().__init__()
|
|
85
|
-
self.dev_mode = dev_mode
|
|
86
79
|
self.use_native_terminal = use_native_terminal
|
|
87
80
|
self._native_terminal_mode = self._detect_native_mode()
|
|
88
81
|
logger.info(
|
|
89
|
-
f"AlineDashboard initialized (
|
|
90
|
-
f"native_terminal={self._native_terminal_mode})"
|
|
82
|
+
f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})"
|
|
91
83
|
)
|
|
92
84
|
|
|
93
85
|
def _detect_native_mode(self) -> bool:
|
|
@@ -103,15 +95,9 @@ class AlineDashboard(App):
|
|
|
103
95
|
logger.debug("compose() started")
|
|
104
96
|
try:
|
|
105
97
|
yield AlineHeader()
|
|
106
|
-
|
|
107
|
-
with TabbedContent(initial=tab_ids[0] if tab_ids else "agents"):
|
|
98
|
+
with TabbedContent(initial="agents"):
|
|
108
99
|
with TabPane("Agents", id="agents"):
|
|
109
100
|
yield AgentsPanel()
|
|
110
|
-
if self.dev_mode:
|
|
111
|
-
with TabPane("Watcher", id="watcher"):
|
|
112
|
-
yield WatcherPanel()
|
|
113
|
-
with TabPane("Worker", id="worker"):
|
|
114
|
-
yield WorkerPanel()
|
|
115
101
|
with TabPane("Config", id="config"):
|
|
116
102
|
yield ConfigPanel()
|
|
117
103
|
yield Footer()
|
|
@@ -121,8 +107,6 @@ class AlineDashboard(App):
|
|
|
121
107
|
raise
|
|
122
108
|
|
|
123
109
|
def _tab_ids(self) -> list[str]:
|
|
124
|
-
if self.dev_mode:
|
|
125
|
-
return ["agents", "watcher", "worker", "config"]
|
|
126
110
|
return ["agents", "config"]
|
|
127
111
|
|
|
128
112
|
def on_mount(self) -> None:
|
|
@@ -207,43 +191,9 @@ class AlineDashboard(App):
|
|
|
207
191
|
|
|
208
192
|
if active_tab_id == "agents":
|
|
209
193
|
self.query_one(AgentsPanel).refresh_data()
|
|
210
|
-
elif active_tab_id == "watcher":
|
|
211
|
-
self.query_one(WatcherPanel).refresh_data()
|
|
212
|
-
elif active_tab_id == "worker":
|
|
213
|
-
self.query_one(WorkerPanel).refresh_data()
|
|
214
194
|
elif active_tab_id == "config":
|
|
215
195
|
self.query_one(ConfigPanel).refresh_data()
|
|
216
196
|
|
|
217
|
-
def action_page_next(self) -> None:
|
|
218
|
-
"""Go to next page in current panel."""
|
|
219
|
-
tabbed_content = self.query_one(TabbedContent)
|
|
220
|
-
active_tab_id = tabbed_content.active
|
|
221
|
-
|
|
222
|
-
if active_tab_id == "watcher":
|
|
223
|
-
self.query_one(WatcherPanel).action_next_page()
|
|
224
|
-
elif active_tab_id == "worker":
|
|
225
|
-
self.query_one(WorkerPanel).action_next_page()
|
|
226
|
-
|
|
227
|
-
def action_page_prev(self) -> None:
|
|
228
|
-
"""Go to previous page in current panel."""
|
|
229
|
-
tabbed_content = self.query_one(TabbedContent)
|
|
230
|
-
active_tab_id = tabbed_content.active
|
|
231
|
-
|
|
232
|
-
if active_tab_id == "watcher":
|
|
233
|
-
self.query_one(WatcherPanel).action_prev_page()
|
|
234
|
-
elif active_tab_id == "worker":
|
|
235
|
-
self.query_one(WorkerPanel).action_prev_page()
|
|
236
|
-
|
|
237
|
-
def action_switch_view(self) -> None:
|
|
238
|
-
"""Switch view in current panel (if supported)."""
|
|
239
|
-
tabbed_content = self.query_one(TabbedContent)
|
|
240
|
-
active_tab_id = tabbed_content.active
|
|
241
|
-
|
|
242
|
-
if active_tab_id == "watcher":
|
|
243
|
-
self.query_one(WatcherPanel).action_switch_view()
|
|
244
|
-
elif active_tab_id == "worker":
|
|
245
|
-
self.query_one(WorkerPanel).action_switch_view()
|
|
246
|
-
|
|
247
197
|
def action_help(self) -> None:
|
|
248
198
|
"""Show help information."""
|
|
249
199
|
from .screens import HelpScreen
|