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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json as _json
|
|
6
7
|
import re
|
|
7
8
|
import shlex
|
|
8
9
|
from pathlib import Path
|
|
@@ -163,9 +164,12 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
163
164
|
super().__init__()
|
|
164
165
|
self._refresh_lock = asyncio.Lock()
|
|
165
166
|
self._agents: list[dict] = []
|
|
167
|
+
self._rendered_fingerprint: str = ""
|
|
166
168
|
self._refresh_worker: Optional[Worker] = None
|
|
167
169
|
self._share_worker: Optional[Worker] = None
|
|
170
|
+
self._sync_worker: Optional[Worker] = None
|
|
168
171
|
self._share_agent_id: Optional[str] = None
|
|
172
|
+
self._sync_agent_id: Optional[str] = None
|
|
169
173
|
self._refresh_timer = None
|
|
170
174
|
|
|
171
175
|
def compose(self) -> ComposeResult:
|
|
@@ -176,7 +180,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
176
180
|
|
|
177
181
|
def on_show(self) -> None:
|
|
178
182
|
if self._refresh_timer is None:
|
|
179
|
-
self._refresh_timer = self.set_interval(
|
|
183
|
+
self._refresh_timer = self.set_interval(1.0, self._on_refresh_timer)
|
|
180
184
|
else:
|
|
181
185
|
try:
|
|
182
186
|
self._refresh_timer.resume()
|
|
@@ -216,18 +220,27 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
216
220
|
agent_infos = db.list_agent_info()
|
|
217
221
|
active_terminals = db.list_agents(status="active", limit=1000)
|
|
218
222
|
|
|
219
|
-
#
|
|
223
|
+
# Latest window links per terminal (V23)
|
|
224
|
+
latest_links = db.list_latest_window_links(limit=2000)
|
|
225
|
+
link_by_terminal = {l.terminal_id: l for l in latest_links if l.terminal_id}
|
|
226
|
+
|
|
227
|
+
# Get tmux windows to retrieve window id and fallback session_id
|
|
220
228
|
tmux_windows = tmux_manager.list_inner_windows()
|
|
221
|
-
# Map terminal_id -> tmux window
|
|
222
229
|
terminal_to_window = {
|
|
223
230
|
w.terminal_id: w for w in tmux_windows if w.terminal_id
|
|
224
231
|
}
|
|
225
232
|
|
|
226
|
-
# Collect all session_ids
|
|
227
|
-
session_ids = [
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
233
|
+
# Collect all session_ids for title lookup
|
|
234
|
+
session_ids: list[str] = []
|
|
235
|
+
for t in active_terminals:
|
|
236
|
+
link = link_by_terminal.get(t.id)
|
|
237
|
+
if link and link.session_id:
|
|
238
|
+
session_ids.append(link.session_id)
|
|
239
|
+
continue
|
|
240
|
+
window = terminal_to_window.get(t.id)
|
|
241
|
+
if window and window.session_id:
|
|
242
|
+
session_ids.append(window.session_id)
|
|
243
|
+
|
|
231
244
|
titles = self._fetch_session_titles(session_ids)
|
|
232
245
|
|
|
233
246
|
# Map agent_info.id -> list of terminals
|
|
@@ -236,16 +249,21 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
236
249
|
# Find which agent_info this terminal belongs to
|
|
237
250
|
agent_info_id = None
|
|
238
251
|
|
|
252
|
+
link = link_by_terminal.get(t.id)
|
|
253
|
+
|
|
239
254
|
# Method 1: Check source field for "agent:{agent_info_id}" format
|
|
240
255
|
source = t.source or ""
|
|
241
256
|
if source.startswith("agent:"):
|
|
242
|
-
agent_info_id = source[6:]
|
|
257
|
+
agent_info_id = source[6:]
|
|
258
|
+
|
|
259
|
+
# Method 2: WindowLink agent_id
|
|
260
|
+
if not agent_info_id and link and link.agent_id:
|
|
261
|
+
agent_info_id = link.agent_id
|
|
243
262
|
|
|
244
|
-
# Method
|
|
263
|
+
# Method 3: Fallback - check tmux window's session.agent_id
|
|
245
264
|
if not agent_info_id:
|
|
246
265
|
window = terminal_to_window.get(t.id)
|
|
247
266
|
if window and window.session_id:
|
|
248
|
-
# Look up session to get agent_id
|
|
249
267
|
session = db.get_session_by_id(window.session_id)
|
|
250
268
|
if session:
|
|
251
269
|
agent_info_id = session.agent_id
|
|
@@ -254,16 +272,18 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
254
272
|
if agent_info_id not in agent_to_terminals:
|
|
255
273
|
agent_to_terminals[agent_info_id] = []
|
|
256
274
|
|
|
257
|
-
# Get session_id
|
|
275
|
+
# Get session_id from windowlink (preferred) or tmux window
|
|
258
276
|
window = terminal_to_window.get(t.id)
|
|
259
|
-
session_id =
|
|
277
|
+
session_id = (
|
|
278
|
+
link.session_id if link and link.session_id else (window.session_id if window else None)
|
|
279
|
+
)
|
|
260
280
|
title = titles.get(session_id, "") if session_id else ""
|
|
261
281
|
|
|
262
282
|
agent_to_terminals[agent_info_id].append(
|
|
263
283
|
{
|
|
264
284
|
"terminal_id": t.id,
|
|
265
285
|
"session_id": session_id,
|
|
266
|
-
"provider": t.provider or "",
|
|
286
|
+
"provider": link.provider if link and link.provider else (t.provider or ""),
|
|
267
287
|
"session_type": t.session_type or "",
|
|
268
288
|
"title": title,
|
|
269
289
|
"cwd": t.cwd or "",
|
|
@@ -278,12 +298,23 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
278
298
|
"name": info.name,
|
|
279
299
|
"description": info.description or "",
|
|
280
300
|
"terminals": terminals,
|
|
301
|
+
"share_url": getattr(info, "share_url", None),
|
|
302
|
+
"last_synced_at": getattr(info, "last_synced_at", None),
|
|
281
303
|
}
|
|
282
304
|
)
|
|
283
305
|
except Exception as e:
|
|
284
306
|
logger.debug(f"Failed to collect agents: {e}")
|
|
285
307
|
return agents
|
|
286
308
|
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _fingerprint(agents: list[dict]) -> str:
|
|
312
|
+
"""Fast serialisation used only for change detection."""
|
|
313
|
+
try:
|
|
314
|
+
return _json.dumps(agents, sort_keys=True, default=str)
|
|
315
|
+
except Exception:
|
|
316
|
+
return ""
|
|
317
|
+
|
|
287
318
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
288
319
|
# Handle refresh worker
|
|
289
320
|
if self._refresh_worker is not None and event.worker is self._refresh_worker:
|
|
@@ -293,6 +324,10 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
293
324
|
self._agents = self._refresh_worker.result or []
|
|
294
325
|
else:
|
|
295
326
|
return
|
|
327
|
+
fp = self._fingerprint(self._agents)
|
|
328
|
+
if fp == self._rendered_fingerprint:
|
|
329
|
+
return # nothing changed – skip re-render to avoid flicker
|
|
330
|
+
self._rendered_fingerprint = fp
|
|
296
331
|
self.run_worker(
|
|
297
332
|
self._render_agents(), group="agents-render", exclusive=True
|
|
298
333
|
)
|
|
@@ -302,6 +337,10 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
302
337
|
if self._share_worker is not None and event.worker is self._share_worker:
|
|
303
338
|
self._handle_share_worker_state_changed(event)
|
|
304
339
|
|
|
340
|
+
# Handle sync worker
|
|
341
|
+
if self._sync_worker is not None and event.worker is self._sync_worker:
|
|
342
|
+
self._handle_sync_worker_state_changed(event)
|
|
343
|
+
|
|
305
344
|
async def _render_agents(self) -> None:
|
|
306
345
|
async with self._refresh_lock:
|
|
307
346
|
try:
|
|
@@ -339,15 +378,25 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
339
378
|
)
|
|
340
379
|
)
|
|
341
380
|
|
|
342
|
-
# Share button
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
381
|
+
# Share or Sync button (Sync if agent already has a share_url)
|
|
382
|
+
if agent.get("share_url"):
|
|
383
|
+
await row.mount(
|
|
384
|
+
Button(
|
|
385
|
+
"Sync",
|
|
386
|
+
id=f"sync-{safe_id}",
|
|
387
|
+
name=agent["id"],
|
|
388
|
+
classes="agent-share",
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
await row.mount(
|
|
393
|
+
Button(
|
|
394
|
+
"Share",
|
|
395
|
+
id=f"share-{safe_id}",
|
|
396
|
+
name=agent["id"],
|
|
397
|
+
classes="agent-share",
|
|
398
|
+
)
|
|
349
399
|
)
|
|
350
|
-
)
|
|
351
400
|
|
|
352
401
|
# Create terminal button
|
|
353
402
|
await row.mount(
|
|
@@ -500,6 +549,11 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
500
549
|
await self._share_agent(agent_id)
|
|
501
550
|
return
|
|
502
551
|
|
|
552
|
+
if btn_id.startswith("sync-"):
|
|
553
|
+
agent_id = event.button.name or ""
|
|
554
|
+
await self._sync_agent(agent_id)
|
|
555
|
+
return
|
|
556
|
+
|
|
503
557
|
if btn_id.startswith("switch-"):
|
|
504
558
|
terminal_id = event.button.name or ""
|
|
505
559
|
await self._switch_to_terminal(terminal_id)
|
|
@@ -543,7 +597,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
543
597
|
|
|
544
598
|
def _on_create_result(self, result: dict | None) -> None:
|
|
545
599
|
if result:
|
|
546
|
-
|
|
600
|
+
if result.get("imported"):
|
|
601
|
+
n = result.get("sessions_imported", 0)
|
|
602
|
+
self.app.notify(
|
|
603
|
+
f"Imported: {result.get('name')} ({n} sessions)", title="Agent"
|
|
604
|
+
)
|
|
605
|
+
else:
|
|
606
|
+
self.app.notify(f"Created: {result.get('name')}", title="Agent")
|
|
547
607
|
self.refresh_data()
|
|
548
608
|
|
|
549
609
|
async def _create_terminal_for_agent(self, agent_id: str) -> None:
|
|
@@ -619,7 +679,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
619
679
|
try:
|
|
620
680
|
from ...codex_home import prepare_codex_home
|
|
621
681
|
|
|
622
|
-
codex_home = prepare_codex_home(terminal_id
|
|
682
|
+
codex_home = prepare_codex_home(terminal_id)
|
|
623
683
|
except Exception:
|
|
624
684
|
codex_home = None
|
|
625
685
|
|
|
@@ -1127,3 +1187,76 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1127
1187
|
self.app.notify(
|
|
1128
1188
|
f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6
|
|
1129
1189
|
)
|
|
1190
|
+
|
|
1191
|
+
async def _sync_agent(self, agent_id: str) -> None:
|
|
1192
|
+
"""Sync all sessions for an agent with remote share."""
|
|
1193
|
+
if not agent_id:
|
|
1194
|
+
return
|
|
1195
|
+
|
|
1196
|
+
# Check if sync is already in progress
|
|
1197
|
+
if self._sync_worker is not None and self._sync_worker.state in (
|
|
1198
|
+
WorkerState.PENDING,
|
|
1199
|
+
WorkerState.RUNNING,
|
|
1200
|
+
):
|
|
1201
|
+
return
|
|
1202
|
+
|
|
1203
|
+
self._sync_agent_id = agent_id
|
|
1204
|
+
|
|
1205
|
+
app = self.app
|
|
1206
|
+
|
|
1207
|
+
def progress_callback(message: str) -> None:
|
|
1208
|
+
try:
|
|
1209
|
+
app.call_from_thread(app.notify, message, title="Sync", timeout=3)
|
|
1210
|
+
except Exception:
|
|
1211
|
+
pass
|
|
1212
|
+
|
|
1213
|
+
def work() -> dict:
|
|
1214
|
+
from ...commands.sync_agent import sync_agent_command
|
|
1215
|
+
|
|
1216
|
+
return sync_agent_command(
|
|
1217
|
+
agent_id=agent_id,
|
|
1218
|
+
progress_callback=progress_callback,
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
self.app.notify("Starting sync...", title="Sync", timeout=2)
|
|
1222
|
+
self._sync_worker = self.run_worker(work, thread=True, exit_on_error=False)
|
|
1223
|
+
|
|
1224
|
+
def _handle_sync_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
1225
|
+
"""Handle sync worker state changes."""
|
|
1226
|
+
if event.state == WorkerState.ERROR:
|
|
1227
|
+
err = self._sync_worker.error if self._sync_worker else "Unknown error"
|
|
1228
|
+
self.app.notify(f"Sync failed: {err}", title="Sync", severity="error")
|
|
1229
|
+
return
|
|
1230
|
+
|
|
1231
|
+
if event.state != WorkerState.SUCCESS:
|
|
1232
|
+
return
|
|
1233
|
+
|
|
1234
|
+
result = self._sync_worker.result if self._sync_worker else {}
|
|
1235
|
+
|
|
1236
|
+
if result.get("success"):
|
|
1237
|
+
pulled = result.get("sessions_pulled", 0)
|
|
1238
|
+
pushed = result.get("sessions_pushed", 0)
|
|
1239
|
+
|
|
1240
|
+
# Copy share URL to clipboard
|
|
1241
|
+
agent_id = self._sync_agent_id
|
|
1242
|
+
share_url = None
|
|
1243
|
+
if agent_id:
|
|
1244
|
+
agent = next((a for a in self._agents if a["id"] == agent_id), None)
|
|
1245
|
+
if agent:
|
|
1246
|
+
share_url = agent.get("share_url")
|
|
1247
|
+
|
|
1248
|
+
if share_url:
|
|
1249
|
+
copied = copy_text(self.app, share_url)
|
|
1250
|
+
suffix = " (link copied)" if copied else ""
|
|
1251
|
+
else:
|
|
1252
|
+
suffix = ""
|
|
1253
|
+
|
|
1254
|
+
self.app.notify(
|
|
1255
|
+
f"Synced: pulled {pulled}, pushed {pushed} session(s){suffix}",
|
|
1256
|
+
title="Sync",
|
|
1257
|
+
timeout=6,
|
|
1258
|
+
)
|
|
1259
|
+
self.refresh_data()
|
|
1260
|
+
else:
|
|
1261
|
+
error = result.get("error", "Unknown error")
|
|
1262
|
+
self.app.notify(f"Sync failed: {error}", title="Sync", severity="error")
|
realign/db/base.py
CHANGED
|
@@ -129,7 +129,7 @@ class AgentRecord:
|
|
|
129
129
|
|
|
130
130
|
@dataclass
|
|
131
131
|
class AgentInfoRecord:
|
|
132
|
-
"""Agent profile/identity data (V20)."""
|
|
132
|
+
"""Agent profile/identity data (V20, V22: sync fields)."""
|
|
133
133
|
|
|
134
134
|
id: str
|
|
135
135
|
name: str
|
|
@@ -137,6 +137,27 @@ class AgentInfoRecord:
|
|
|
137
137
|
updated_at: datetime
|
|
138
138
|
description: Optional[str] = ""
|
|
139
139
|
visibility: str = "visible"
|
|
140
|
+
# V22: sync metadata
|
|
141
|
+
share_id: Optional[str] = None
|
|
142
|
+
share_url: Optional[str] = None
|
|
143
|
+
share_admin_token: Optional[str] = None
|
|
144
|
+
share_contributor_token: Optional[str] = None
|
|
145
|
+
share_expiry_at: Optional[str] = None
|
|
146
|
+
last_synced_at: Optional[str] = None
|
|
147
|
+
sync_version: int = 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class WindowLinkRecord:
|
|
152
|
+
"""Represents a terminal/session association (V23)."""
|
|
153
|
+
|
|
154
|
+
terminal_id: str
|
|
155
|
+
agent_id: Optional[str] = None
|
|
156
|
+
session_id: Optional[str] = None
|
|
157
|
+
provider: Optional[str] = None
|
|
158
|
+
source: Optional[str] = None
|
|
159
|
+
ts: Optional[float] = None
|
|
160
|
+
created_at: Optional[datetime] = None
|
|
140
161
|
|
|
141
162
|
|
|
142
163
|
@dataclass
|
|
@@ -228,6 +249,27 @@ class DatabaseInterface(ABC):
|
|
|
228
249
|
"""Backfill total_turns for all sessions from turns table (V10 migration)."""
|
|
229
250
|
pass
|
|
230
251
|
|
|
252
|
+
@abstractmethod
|
|
253
|
+
def insert_window_link(
|
|
254
|
+
self,
|
|
255
|
+
*,
|
|
256
|
+
terminal_id: str,
|
|
257
|
+
agent_id: Optional[str],
|
|
258
|
+
session_id: Optional[str],
|
|
259
|
+
provider: Optional[str],
|
|
260
|
+
source: Optional[str],
|
|
261
|
+
ts: Optional[float] = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Insert a window link record (V23)."""
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
@abstractmethod
|
|
267
|
+
def list_latest_window_links(
|
|
268
|
+
self, *, agent_id: Optional[str] = None, limit: int = 1000
|
|
269
|
+
) -> List[WindowLinkRecord]:
|
|
270
|
+
"""List latest window link per terminal (V23)."""
|
|
271
|
+
pass
|
|
272
|
+
|
|
231
273
|
@abstractmethod
|
|
232
274
|
def list_sessions(
|
|
233
275
|
self, limit: int = 100, workspace_path: Optional[str] = None
|
realign/db/schema.py
CHANGED
|
@@ -82,9 +82,12 @@ Schema V19: Agent association for sessions.
|
|
|
82
82
|
|
|
83
83
|
Schema V20: Agent identity/profile table.
|
|
84
84
|
- agent_info table: name, description for agent profiles
|
|
85
|
+
|
|
86
|
+
Schema V23: WindowLink mapping for terminal/session association.
|
|
87
|
+
- windowlink table: terminal_id/agent_id/session_id with timestamp
|
|
85
88
|
"""
|
|
86
89
|
|
|
87
|
-
SCHEMA_VERSION =
|
|
90
|
+
SCHEMA_VERSION = 23
|
|
88
91
|
|
|
89
92
|
FTS_EVENTS_SCRIPTS = [
|
|
90
93
|
# Full Text Search for Events
|
|
@@ -343,13 +346,20 @@ INIT_SCRIPTS = [
|
|
|
343
346
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
344
347
|
);
|
|
345
348
|
""",
|
|
346
|
-
# Agent identity/profile table (V20)
|
|
349
|
+
# Agent identity/profile table (V20, V22: sync columns)
|
|
347
350
|
"""
|
|
348
351
|
CREATE TABLE IF NOT EXISTS agent_info (
|
|
349
352
|
id TEXT PRIMARY KEY,
|
|
350
353
|
name TEXT NOT NULL,
|
|
351
354
|
description TEXT DEFAULT '',
|
|
352
355
|
visibility TEXT NOT NULL DEFAULT 'visible',
|
|
356
|
+
share_id TEXT,
|
|
357
|
+
share_url TEXT,
|
|
358
|
+
share_admin_token TEXT,
|
|
359
|
+
share_contributor_token TEXT,
|
|
360
|
+
share_expiry_at TEXT,
|
|
361
|
+
last_synced_at TEXT,
|
|
362
|
+
sync_version INTEGER DEFAULT 0,
|
|
353
363
|
created_at TEXT DEFAULT (datetime('now')),
|
|
354
364
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
355
365
|
);
|
|
@@ -405,6 +415,21 @@ MIGRATION_V1_TO_V2 = [
|
|
|
405
415
|
"""
|
|
406
416
|
CREATE INDEX IF NOT EXISTS idx_sessions_type ON sessions(session_type);
|
|
407
417
|
""",
|
|
418
|
+
# WindowLink table (V23)
|
|
419
|
+
"""
|
|
420
|
+
CREATE TABLE IF NOT EXISTS windowlink (
|
|
421
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
422
|
+
terminal_id TEXT NOT NULL,
|
|
423
|
+
agent_id TEXT,
|
|
424
|
+
session_id TEXT,
|
|
425
|
+
provider TEXT,
|
|
426
|
+
source TEXT,
|
|
427
|
+
ts REAL,
|
|
428
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
429
|
+
);
|
|
430
|
+
""",
|
|
431
|
+
"CREATE INDEX IF NOT EXISTS idx_windowlink_terminal_ts ON windowlink(terminal_id, ts DESC);",
|
|
432
|
+
"CREATE INDEX IF NOT EXISTS idx_windowlink_agent_ts ON windowlink(agent_id, ts DESC);",
|
|
408
433
|
]
|
|
409
434
|
|
|
410
435
|
# Migration scripts from V2 to V3
|
|
@@ -693,6 +718,33 @@ MIGRATION_V20_TO_V21 = [
|
|
|
693
718
|
"ALTER TABLE agent_info ADD COLUMN visibility TEXT NOT NULL DEFAULT 'visible';",
|
|
694
719
|
]
|
|
695
720
|
|
|
721
|
+
MIGRATION_V21_TO_V22 = [
|
|
722
|
+
"ALTER TABLE agent_info ADD COLUMN share_id TEXT;",
|
|
723
|
+
"ALTER TABLE agent_info ADD COLUMN share_url TEXT;",
|
|
724
|
+
"ALTER TABLE agent_info ADD COLUMN share_admin_token TEXT;",
|
|
725
|
+
"ALTER TABLE agent_info ADD COLUMN share_contributor_token TEXT;",
|
|
726
|
+
"ALTER TABLE agent_info ADD COLUMN share_expiry_at TEXT;",
|
|
727
|
+
"ALTER TABLE agent_info ADD COLUMN last_synced_at TEXT;",
|
|
728
|
+
"ALTER TABLE agent_info ADD COLUMN sync_version INTEGER DEFAULT 0;",
|
|
729
|
+
]
|
|
730
|
+
|
|
731
|
+
MIGRATION_V22_TO_V23 = [
|
|
732
|
+
"""
|
|
733
|
+
CREATE TABLE IF NOT EXISTS windowlink (
|
|
734
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
735
|
+
terminal_id TEXT NOT NULL,
|
|
736
|
+
agent_id TEXT,
|
|
737
|
+
session_id TEXT,
|
|
738
|
+
provider TEXT,
|
|
739
|
+
source TEXT,
|
|
740
|
+
ts REAL,
|
|
741
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
742
|
+
);
|
|
743
|
+
""",
|
|
744
|
+
"CREATE INDEX IF NOT EXISTS idx_windowlink_terminal_ts ON windowlink(terminal_id, ts DESC);",
|
|
745
|
+
"CREATE INDEX IF NOT EXISTS idx_windowlink_agent_ts ON windowlink(agent_id, ts DESC);",
|
|
746
|
+
]
|
|
747
|
+
|
|
696
748
|
|
|
697
749
|
def get_migration_scripts(from_version: int, to_version: int) -> list:
|
|
698
750
|
"""Get migration scripts for upgrading between versions."""
|
|
@@ -767,4 +819,10 @@ def get_migration_scripts(from_version: int, to_version: int) -> list:
|
|
|
767
819
|
if from_version < 21 and to_version >= 21:
|
|
768
820
|
scripts.extend(MIGRATION_V20_TO_V21)
|
|
769
821
|
|
|
822
|
+
if from_version < 22 and to_version >= 22:
|
|
823
|
+
scripts.extend(MIGRATION_V21_TO_V22)
|
|
824
|
+
|
|
825
|
+
if from_version < 23 and to_version >= 23:
|
|
826
|
+
scripts.extend(MIGRATION_V22_TO_V23)
|
|
827
|
+
|
|
770
828
|
return scripts
|
realign/db/sqlite_db.py
CHANGED
|
@@ -23,6 +23,7 @@ from .base import (
|
|
|
23
23
|
AgentRecord,
|
|
24
24
|
AgentInfoRecord,
|
|
25
25
|
AgentContextRecord,
|
|
26
|
+
WindowLinkRecord,
|
|
26
27
|
UserRecord,
|
|
27
28
|
)
|
|
28
29
|
from .schema import (
|
|
@@ -2395,6 +2396,92 @@ class SQLiteDatabase(DatabaseInterface):
|
|
|
2395
2396
|
except sqlite3.OperationalError:
|
|
2396
2397
|
return []
|
|
2397
2398
|
|
|
2399
|
+
def insert_window_link(
|
|
2400
|
+
self,
|
|
2401
|
+
*,
|
|
2402
|
+
terminal_id: str,
|
|
2403
|
+
agent_id: Optional[str],
|
|
2404
|
+
session_id: Optional[str],
|
|
2405
|
+
provider: Optional[str],
|
|
2406
|
+
source: Optional[str],
|
|
2407
|
+
ts: Optional[float] = None,
|
|
2408
|
+
) -> None:
|
|
2409
|
+
conn = self._get_connection()
|
|
2410
|
+
cursor = conn.cursor()
|
|
2411
|
+
ts_value = ts if ts is not None else time.time()
|
|
2412
|
+
try:
|
|
2413
|
+
cursor.execute(
|
|
2414
|
+
"""
|
|
2415
|
+
INSERT INTO windowlink (
|
|
2416
|
+
terminal_id, agent_id, session_id, provider, source, ts
|
|
2417
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
2418
|
+
""",
|
|
2419
|
+
(
|
|
2420
|
+
terminal_id,
|
|
2421
|
+
agent_id,
|
|
2422
|
+
session_id,
|
|
2423
|
+
provider,
|
|
2424
|
+
source,
|
|
2425
|
+
float(ts_value),
|
|
2426
|
+
),
|
|
2427
|
+
)
|
|
2428
|
+
conn.commit()
|
|
2429
|
+
except sqlite3.OperationalError:
|
|
2430
|
+
conn.rollback()
|
|
2431
|
+
|
|
2432
|
+
def list_latest_window_links(
|
|
2433
|
+
self, *, agent_id: Optional[str] = None, limit: int = 1000
|
|
2434
|
+
) -> List[WindowLinkRecord]:
|
|
2435
|
+
conn = self._get_connection()
|
|
2436
|
+
cursor = conn.cursor()
|
|
2437
|
+
params: List[Any] = []
|
|
2438
|
+
where_clause = ""
|
|
2439
|
+
if agent_id:
|
|
2440
|
+
where_clause = "WHERE agent_id = ?"
|
|
2441
|
+
params.append(agent_id)
|
|
2442
|
+
params.append(limit)
|
|
2443
|
+
try:
|
|
2444
|
+
cursor.execute(
|
|
2445
|
+
f"""
|
|
2446
|
+
SELECT terminal_id, agent_id, session_id, provider, source, ts, created_at
|
|
2447
|
+
FROM (
|
|
2448
|
+
SELECT terminal_id,
|
|
2449
|
+
agent_id,
|
|
2450
|
+
session_id,
|
|
2451
|
+
provider,
|
|
2452
|
+
source,
|
|
2453
|
+
ts,
|
|
2454
|
+
created_at,
|
|
2455
|
+
ROW_NUMBER() OVER (
|
|
2456
|
+
PARTITION BY terminal_id
|
|
2457
|
+
ORDER BY ts DESC, id DESC
|
|
2458
|
+
) AS rn
|
|
2459
|
+
FROM windowlink
|
|
2460
|
+
{where_clause}
|
|
2461
|
+
) AS ranked
|
|
2462
|
+
WHERE rn = 1
|
|
2463
|
+
LIMIT ?
|
|
2464
|
+
""",
|
|
2465
|
+
params,
|
|
2466
|
+
)
|
|
2467
|
+
rows = cursor.fetchall()
|
|
2468
|
+
out: List[WindowLinkRecord] = []
|
|
2469
|
+
for row in rows:
|
|
2470
|
+
out.append(
|
|
2471
|
+
WindowLinkRecord(
|
|
2472
|
+
terminal_id=row[0],
|
|
2473
|
+
agent_id=row[1],
|
|
2474
|
+
session_id=row[2],
|
|
2475
|
+
provider=row[3],
|
|
2476
|
+
source=row[4],
|
|
2477
|
+
ts=row[5],
|
|
2478
|
+
created_at=self._parse_datetime(row[6]) if row[6] else None,
|
|
2479
|
+
)
|
|
2480
|
+
)
|
|
2481
|
+
return out
|
|
2482
|
+
except sqlite3.OperationalError:
|
|
2483
|
+
return []
|
|
2484
|
+
|
|
2398
2485
|
def delete_agent(self, agent_id: str) -> bool:
|
|
2399
2486
|
"""Delete an agent by ID."""
|
|
2400
2487
|
conn = self._get_connection()
|
|
@@ -3081,12 +3168,20 @@ class SQLiteDatabase(DatabaseInterface):
|
|
|
3081
3168
|
|
|
3082
3169
|
def _row_to_agent_info(self, row: sqlite3.Row) -> AgentInfoRecord:
|
|
3083
3170
|
"""Convert a database row to an AgentInfoRecord."""
|
|
3171
|
+
keys = row.keys()
|
|
3084
3172
|
visibility = "visible"
|
|
3085
3173
|
try:
|
|
3086
|
-
if "visibility" in
|
|
3174
|
+
if "visibility" in keys:
|
|
3087
3175
|
visibility = row["visibility"] or "visible"
|
|
3088
3176
|
except Exception:
|
|
3089
3177
|
visibility = "visible"
|
|
3178
|
+
|
|
3179
|
+
def _safe_get(key: str, default=None):
|
|
3180
|
+
try:
|
|
3181
|
+
return row[key] if key in keys else default
|
|
3182
|
+
except Exception:
|
|
3183
|
+
return default
|
|
3184
|
+
|
|
3090
3185
|
return AgentInfoRecord(
|
|
3091
3186
|
id=row["id"],
|
|
3092
3187
|
name=row["name"],
|
|
@@ -3094,6 +3189,13 @@ class SQLiteDatabase(DatabaseInterface):
|
|
|
3094
3189
|
created_at=self._parse_datetime(row["created_at"]),
|
|
3095
3190
|
updated_at=self._parse_datetime(row["updated_at"]),
|
|
3096
3191
|
visibility=visibility,
|
|
3192
|
+
share_id=_safe_get("share_id"),
|
|
3193
|
+
share_url=_safe_get("share_url"),
|
|
3194
|
+
share_admin_token=_safe_get("share_admin_token"),
|
|
3195
|
+
share_contributor_token=_safe_get("share_contributor_token"),
|
|
3196
|
+
share_expiry_at=_safe_get("share_expiry_at"),
|
|
3197
|
+
last_synced_at=_safe_get("last_synced_at"),
|
|
3198
|
+
sync_version=_safe_get("sync_version", 0) or 0,
|
|
3097
3199
|
)
|
|
3098
3200
|
|
|
3099
3201
|
def get_or_create_agent_info(
|
|
@@ -3198,3 +3300,76 @@ class SQLiteDatabase(DatabaseInterface):
|
|
|
3198
3300
|
return [self._row_to_agent_info(row) for row in cursor.fetchall()]
|
|
3199
3301
|
except sqlite3.OperationalError:
|
|
3200
3302
|
return []
|
|
3303
|
+
|
|
3304
|
+
def update_agent_sync_metadata(
|
|
3305
|
+
self,
|
|
3306
|
+
agent_id: str,
|
|
3307
|
+
*,
|
|
3308
|
+
share_id: Optional[str] = None,
|
|
3309
|
+
share_url: Optional[str] = None,
|
|
3310
|
+
share_admin_token: Optional[str] = None,
|
|
3311
|
+
share_contributor_token: Optional[str] = None,
|
|
3312
|
+
share_expiry_at: Optional[str] = None,
|
|
3313
|
+
last_synced_at: Optional[str] = None,
|
|
3314
|
+
sync_version: Optional[int] = None,
|
|
3315
|
+
) -> Optional[AgentInfoRecord]:
|
|
3316
|
+
"""Update sync-related metadata on an agent_info record."""
|
|
3317
|
+
conn = self._get_connection()
|
|
3318
|
+
sets: list[str] = []
|
|
3319
|
+
params: list[Any] = []
|
|
3320
|
+
|
|
3321
|
+
if share_id is not None:
|
|
3322
|
+
sets.append("share_id = ?")
|
|
3323
|
+
params.append(share_id)
|
|
3324
|
+
if share_url is not None:
|
|
3325
|
+
sets.append("share_url = ?")
|
|
3326
|
+
params.append(share_url)
|
|
3327
|
+
if share_admin_token is not None:
|
|
3328
|
+
sets.append("share_admin_token = ?")
|
|
3329
|
+
params.append(share_admin_token)
|
|
3330
|
+
if share_contributor_token is not None:
|
|
3331
|
+
sets.append("share_contributor_token = ?")
|
|
3332
|
+
params.append(share_contributor_token)
|
|
3333
|
+
if share_expiry_at is not None:
|
|
3334
|
+
sets.append("share_expiry_at = ?")
|
|
3335
|
+
params.append(share_expiry_at)
|
|
3336
|
+
if last_synced_at is not None:
|
|
3337
|
+
sets.append("last_synced_at = ?")
|
|
3338
|
+
params.append(last_synced_at)
|
|
3339
|
+
if sync_version is not None:
|
|
3340
|
+
sets.append("sync_version = ?")
|
|
3341
|
+
params.append(sync_version)
|
|
3342
|
+
|
|
3343
|
+
if not sets:
|
|
3344
|
+
return self.get_agent_info(agent_id)
|
|
3345
|
+
|
|
3346
|
+
sets.append("updated_at = datetime('now')")
|
|
3347
|
+
params.append(agent_id)
|
|
3348
|
+
|
|
3349
|
+
try:
|
|
3350
|
+
conn.execute(
|
|
3351
|
+
f"UPDATE agent_info SET {', '.join(sets)} WHERE id = ?",
|
|
3352
|
+
params,
|
|
3353
|
+
)
|
|
3354
|
+
conn.commit()
|
|
3355
|
+
except sqlite3.OperationalError:
|
|
3356
|
+
return None
|
|
3357
|
+
|
|
3358
|
+
return self.get_agent_info(agent_id)
|
|
3359
|
+
|
|
3360
|
+
def get_agent_content_hashes(self, agent_id: str) -> set[str]:
|
|
3361
|
+
"""Return all content_hash values for turns in this agent's sessions."""
|
|
3362
|
+
conn = self._get_connection()
|
|
3363
|
+
try:
|
|
3364
|
+
cursor = conn.execute(
|
|
3365
|
+
"""
|
|
3366
|
+
SELECT DISTINCT t.content_hash
|
|
3367
|
+
FROM turns t
|
|
3368
|
+
JOIN sessions s ON t.session_id = s.id
|
|
3369
|
+
WHERE s.agent_id = ?
|
|
3370
|
+
""",
|
|
3371
|
+
(agent_id,),
|
|
3372
|
+
)
|
|
3373
|
+
return {row["content_hash"] for row in cursor.fetchall()}
|
|
3374
|
+
except sqlite3.OperationalError:
|
|
3375
|
+
return set()
|