aline-ai 0.5.8__py3-none-any.whl → 0.5.10__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.
@@ -6,6 +6,7 @@ import json
6
6
  import os
7
7
  import shutil
8
8
  import subprocess
9
+ import traceback
9
10
  from datetime import datetime
10
11
  from typing import Optional, Set
11
12
  from urllib.parse import urlparse
@@ -18,8 +19,11 @@ from textual.reactive import reactive
18
19
  from textual.worker import Worker, WorkerState
19
20
  from textual.widgets import Button, DataTable, Static
20
21
 
22
+ from ...logging_config import setup_logger
21
23
  from .openable_table import OpenableDataTable
22
24
 
25
+ logger = setup_logger("realign.dashboard.events", "dashboard.log")
26
+
23
27
 
24
28
  class EventsListTable(OpenableDataTable):
25
29
  """Events list table with multi-select behavior."""
@@ -123,38 +127,50 @@ class EventsTable(Container):
123
127
 
124
128
  def compose(self) -> ComposeResult:
125
129
  """Compose the events table layout."""
126
- with Vertical(classes="action-section"):
127
- yield Button(
128
- "Load selected context to current agent",
129
- id="load-context-btn",
130
- variant="primary",
131
- disabled=True,
132
- )
133
- yield Button(
134
- "Share selected events to others",
135
- id="share-event-btn",
136
- variant="primary",
137
- disabled=True,
138
- )
139
- yield Button(
140
- "Import context from others",
141
- id="share-import-btn",
142
- variant="primary",
143
- )
144
- yield Static(id="section-header", classes="section-header")
145
- with Container(classes="table-container"):
146
- yield EventsListTable(id="events-table")
147
- yield Static(id="pagination-info", classes="pagination-info")
148
- yield Static(id="stats-info", classes="stats-info")
130
+ logger.debug("EventsTable.compose() started")
131
+ try:
132
+ with Vertical(classes="action-section"):
133
+ yield Button(
134
+ "Load selected context to current agent",
135
+ id="load-context-btn",
136
+ variant="primary",
137
+ disabled=True,
138
+ )
139
+ yield Button(
140
+ "Share selected events to others",
141
+ id="share-event-btn",
142
+ variant="primary",
143
+ disabled=True,
144
+ )
145
+ yield Button(
146
+ "Import context from others",
147
+ id="share-import-btn",
148
+ variant="primary",
149
+ )
150
+ yield Static(id="section-header", classes="section-header")
151
+ with Container(classes="table-container"):
152
+ yield EventsListTable(id="events-table")
153
+ yield Static(id="pagination-info", classes="pagination-info")
154
+ yield Static(id="stats-info", classes="stats-info")
155
+ logger.debug("EventsTable.compose() completed")
156
+ except Exception as e:
157
+ logger.error(f"EventsTable.compose() failed: {e}\n{traceback.format_exc()}")
158
+ raise
149
159
 
150
160
  def on_mount(self) -> None:
151
161
  """Set up the table on mount."""
152
- table = self.query_one("#events-table", EventsListTable)
153
- table.owner = self
154
- self._setup_table_columns(table)
155
-
156
- # Calculate initial rows per page
157
- self._calculate_rows_per_page()
162
+ logger.debug("EventsTable.on_mount() started")
163
+ try:
164
+ table = self.query_one("#events-table", EventsListTable)
165
+ table.owner = self
166
+ self._setup_table_columns(table)
167
+
168
+ # Calculate initial rows per page
169
+ self._calculate_rows_per_page()
170
+ logger.debug("EventsTable.on_mount() completed")
171
+ except Exception as e:
172
+ logger.error(f"EventsTable.on_mount() failed: {e}\n{traceback.format_exc()}")
173
+ raise
158
174
 
159
175
  def on_resize(self) -> None:
160
176
  """Handle window resize to adjust rows per page."""
@@ -203,8 +203,8 @@ class TerminalPanel(Container, can_focus=True):
203
203
  }
204
204
 
205
205
  TerminalPanel .terminal-row Button.terminal-close {
206
- width: 2;
207
- min-width: 2;
206
+ width: 3;
207
+ min-width: 3;
208
208
  height: 2;
209
209
  margin-left: 1;
210
210
  padding: 0;
@@ -221,8 +221,8 @@ class TerminalPanel(Container, can_focus=True):
221
221
  }
222
222
 
223
223
  TerminalPanel .terminal-row Button.terminal-toggle {
224
- width: 2;
225
- min-width: 2;
224
+ width: 3;
225
+ min-width: 3;
226
226
  height: 2;
227
227
  margin-left: 1;
228
228
  padding: 0;
@@ -623,22 +623,24 @@ class TerminalPanel(Container, can_focus=True):
623
623
  """Wrap a command to run in a specific directory."""
624
624
  return f"cd {shlex.quote(directory)} && {command}"
625
625
 
626
- def _on_create_agent_result(self, result: tuple[str, str] | None) -> None:
626
+ def _on_create_agent_result(self, result: tuple[str, str, bool] | None) -> None:
627
627
  """Handle the result from CreateAgentScreen modal."""
628
628
  if result is None:
629
629
  return
630
630
 
631
- agent_type, workspace = result
631
+ agent_type, workspace, skip_permissions = result
632
632
  self.run_worker(
633
- self._create_agent(agent_type, workspace),
633
+ self._create_agent(agent_type, workspace, skip_permissions=skip_permissions),
634
634
  group="terminal-panel-create",
635
635
  exclusive=True,
636
636
  )
637
637
 
638
- async def _create_agent(self, agent_type: str, workspace: str) -> None:
638
+ async def _create_agent(
639
+ self, agent_type: str, workspace: str, *, skip_permissions: bool = False
640
+ ) -> None:
639
641
  """Create a new agent terminal based on the selected type and workspace."""
640
642
  if agent_type == "claude":
641
- await self._create_claude_terminal(workspace)
643
+ await self._create_claude_terminal(workspace, skip_permissions=skip_permissions)
642
644
  elif agent_type == "codex":
643
645
  await self._create_codex_terminal(workspace)
644
646
  elif agent_type == "opencode":
@@ -647,7 +649,9 @@ class TerminalPanel(Container, can_focus=True):
647
649
  await self._create_zsh_terminal(workspace)
648
650
  await self.refresh_data()
649
651
 
650
- async def _create_claude_terminal(self, workspace: str) -> None:
652
+ async def _create_claude_terminal(
653
+ self, workspace: str, *, skip_permissions: bool = False
654
+ ) -> None:
651
655
  """Create a new Claude terminal."""
652
656
  terminal_id = tmux_manager.new_terminal_id()
653
657
  context_id = tmux_manager.new_context_id("cc")
@@ -708,8 +712,11 @@ class TerminalPanel(Container, can_focus=True):
708
712
  except Exception:
709
713
  pass
710
714
 
715
+ claude_cmd = "claude"
716
+ if skip_permissions:
717
+ claude_cmd = "claude --dangerously-skip-permissions"
711
718
  command = self._command_in_directory(
712
- tmux_manager.zsh_run_and_keep_open("claude"), workspace
719
+ tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
713
720
  )
714
721
  created = tmux_manager.create_inner_window(
715
722
  "cc",
realign/db/__init__.py CHANGED
@@ -8,6 +8,47 @@ from .base import DatabaseInterface
8
8
  from .sqlite_db import SQLiteDatabase
9
9
 
10
10
  _DB_INSTANCE = None
11
+ _MIGRATION_DONE = False
12
+
13
+
14
+ def _auto_migrate_agents_data(db: SQLiteDatabase) -> None:
15
+ """Auto-migrate terminal.json and load.json data to SQLite (runs once).
16
+
17
+ This is triggered automatically when the schema is upgraded to V15.
18
+ Uses a marker file to avoid running multiple times.
19
+ """
20
+ global _MIGRATION_DONE
21
+ if _MIGRATION_DONE:
22
+ return
23
+
24
+ # Check marker file
25
+ marker_path = Path.home() / ".aline" / ".agents_migrated_v15"
26
+ if marker_path.exists():
27
+ _MIGRATION_DONE = True
28
+ return
29
+
30
+ # Skip during tests
31
+ if os.getenv("PYTEST_CURRENT_TEST"):
32
+ _MIGRATION_DONE = True
33
+ return
34
+
35
+ try:
36
+ from .migrate_agents import migrate_terminal_json, migrate_load_json
37
+
38
+ # Run migrations silently (no dry_run)
39
+ agents_count = migrate_terminal_json(db, dry_run=False, silent=True)
40
+ contexts_count = migrate_load_json(db, dry_run=False, silent=True)
41
+
42
+ # Create marker file
43
+ marker_path.parent.mkdir(parents=True, exist_ok=True)
44
+ marker_path.write_text(
45
+ f"Migrated: {agents_count} agents, {contexts_count} contexts\n"
46
+ )
47
+
48
+ _MIGRATION_DONE = True
49
+ except Exception:
50
+ # Don't fail if migration fails - JSON fallback will still work
51
+ _MIGRATION_DONE = True
11
52
 
12
53
 
13
54
  def get_database(
@@ -54,4 +95,7 @@ def get_database(
54
95
  _DB_INSTANCE = SQLiteDatabase(db_path, connect_timeout_seconds=timeout)
55
96
  _DB_INSTANCE.initialize()
56
97
 
98
+ # Auto-migrate JSON data to SQLite (runs once after V15 upgrade)
99
+ _auto_migrate_agents_data(_DB_INSTANCE)
100
+
57
101
  return _DB_INSTANCE
realign/db/base.py CHANGED
@@ -107,6 +107,42 @@ class EventRecord:
107
107
  creator_id: Optional[str] = None
108
108
 
109
109
 
110
+ @dataclass
111
+ class AgentRecord:
112
+ """Represents a terminal/agent mapping (V15: replaces terminal.json)."""
113
+
114
+ id: str # terminal_id (UUID)
115
+ provider: str # 'claude', 'codex', 'opencode', 'zsh'
116
+ session_type: str
117
+ created_at: datetime
118
+ updated_at: datetime
119
+ session_id: Optional[str] = None # FK to sessions.id
120
+ context_id: Optional[str] = None
121
+ transcript_path: Optional[str] = None
122
+ cwd: Optional[str] = None
123
+ project_dir: Optional[str] = None
124
+ status: str = "active" # 'active', 'stopped'
125
+ attention: Optional[str] = None # 'permission_request', 'stop', or None
126
+ source: Optional[str] = None
127
+ creator_name: Optional[str] = None
128
+ creator_id: Optional[str] = None
129
+
130
+
131
+ @dataclass
132
+ class AgentContextRecord:
133
+ """Represents a context entry (V15: replaces load.json)."""
134
+
135
+ id: str # context_id
136
+ created_at: datetime
137
+ updated_at: datetime
138
+ workspace: Optional[str] = None
139
+ loaded_at: Optional[str] = None
140
+ metadata: Optional[Dict[str, Any]] = None
141
+ # Populated when reading (from M2M tables)
142
+ session_ids: Optional[List[str]] = None
143
+ event_ids: Optional[List[str]] = None
144
+
145
+
110
146
  class DatabaseInterface(ABC):
111
147
  """Abstract interface for ReAlign storage backend."""
112
148
 
@@ -0,0 +1,297 @@
1
+ """Migration script to import terminal.json and load.json data into SQLite.
2
+
3
+ This script migrates:
4
+ - ~/.aline/terminal.json -> agents table
5
+ - ~/.aline/load.json -> agent_contexts, agent_context_sessions, agent_context_events tables
6
+
7
+ Usage:
8
+ python -m realign.db.migrate_agents [--backup] [--dry-run]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ def migrate_terminal_json(db, dry_run: bool = False, silent: bool = False) -> int:
22
+ """Migrate terminal.json to agents table.
23
+
24
+ Args:
25
+ db: Database instance
26
+ dry_run: If True, don't actually migrate
27
+ silent: If True, don't print any output
28
+
29
+ Returns:
30
+ Number of agents migrated
31
+ """
32
+ path = Path.home() / ".aline" / "terminal.json"
33
+ if not path.exists():
34
+ if not silent:
35
+ print(f"[migrate] terminal.json not found at {path}, skipping")
36
+ return 0
37
+
38
+ try:
39
+ payload = json.loads(path.read_text(encoding="utf-8"))
40
+ except Exception as e:
41
+ if not silent:
42
+ print(f"[migrate] Failed to read terminal.json: {e}")
43
+ return 0
44
+
45
+ terminals = payload.get("terminals", {})
46
+ if not isinstance(terminals, dict):
47
+ if not silent:
48
+ print("[migrate] terminal.json has no 'terminals' dict, skipping")
49
+ return 0
50
+
51
+ migrated = 0
52
+ for terminal_id, data in terminals.items():
53
+ if not isinstance(terminal_id, str) or not isinstance(data, dict):
54
+ continue
55
+
56
+ provider = data.get("provider", "unknown")
57
+ session_type = data.get("session_type", provider)
58
+ session_id = data.get("session_id") or None
59
+ transcript_path = data.get("transcript_path") or None
60
+ cwd = data.get("cwd") or None
61
+ project_dir = data.get("project_dir") or None
62
+ source = data.get("source") or None
63
+ context_id = data.get("context_id") or None
64
+ attention = data.get("attention") or None
65
+
66
+ if dry_run:
67
+ if not silent:
68
+ print(f"[dry-run] Would migrate agent: {terminal_id[:8]}... ({provider})")
69
+ migrated += 1
70
+ continue
71
+
72
+ try:
73
+ existing = db.get_agent_by_id(terminal_id)
74
+ if existing:
75
+ if not silent:
76
+ print(f"[migrate] Agent {terminal_id[:8]}... already exists, skipping")
77
+ continue
78
+
79
+ db.get_or_create_agent(
80
+ terminal_id,
81
+ provider=provider,
82
+ session_type=session_type,
83
+ session_id=session_id,
84
+ context_id=context_id,
85
+ transcript_path=transcript_path,
86
+ cwd=cwd,
87
+ project_dir=project_dir,
88
+ source=source,
89
+ attention=attention,
90
+ )
91
+ if not silent:
92
+ print(f"[migrate] Migrated agent: {terminal_id[:8]}... ({provider})")
93
+ migrated += 1
94
+ except Exception as e:
95
+ if not silent:
96
+ print(f"[migrate] Failed to migrate agent {terminal_id[:8]}...: {e}")
97
+
98
+ return migrated
99
+
100
+
101
+ def migrate_load_json(db, dry_run: bool = False, silent: bool = False) -> int:
102
+ """Migrate load.json to agent_contexts tables.
103
+
104
+ Args:
105
+ db: Database instance
106
+ dry_run: If True, don't actually migrate
107
+ silent: If True, don't print any output
108
+
109
+ Returns:
110
+ Number of contexts migrated
111
+ """
112
+ path = Path.home() / ".aline" / "load.json"
113
+ if not path.exists():
114
+ if not silent:
115
+ print(f"[migrate] load.json not found at {path}, skipping")
116
+ return 0
117
+
118
+ try:
119
+ payload = json.loads(path.read_text(encoding="utf-8"))
120
+ except Exception as e:
121
+ if not silent:
122
+ print(f"[migrate] Failed to read load.json: {e}")
123
+ return 0
124
+
125
+ contexts = payload.get("contexts", [])
126
+ if not isinstance(contexts, list):
127
+ if not silent:
128
+ print("[migrate] load.json has no 'contexts' list, skipping")
129
+ return 0
130
+
131
+ migrated = 0
132
+ for ctx_data in contexts:
133
+ if not isinstance(ctx_data, dict):
134
+ continue
135
+
136
+ context_id = ctx_data.get("context_id")
137
+ if not context_id:
138
+ # Generate context_id from workspace if not present
139
+ workspace = ctx_data.get("workspace")
140
+ if workspace:
141
+ # Create a deterministic ID from workspace
142
+ import hashlib
143
+
144
+ context_id = f"ws-{hashlib.sha256(workspace.encode()).hexdigest()[:12]}"
145
+ else:
146
+ continue
147
+
148
+ workspace = ctx_data.get("workspace")
149
+ loaded_at = ctx_data.get("loaded_at")
150
+ context_sessions = ctx_data.get("context_sessions", [])
151
+ context_events = ctx_data.get("context_events", [])
152
+
153
+ if dry_run:
154
+ if not silent:
155
+ print(
156
+ f"[dry-run] Would migrate context: {context_id} "
157
+ f"(sessions={len(context_sessions)}, events={len(context_events)})"
158
+ )
159
+ migrated += 1
160
+ continue
161
+
162
+ try:
163
+ existing = db.get_agent_context_by_id(context_id)
164
+ if existing:
165
+ if not silent:
166
+ print(f"[migrate] Context {context_id} already exists, updating links")
167
+ else:
168
+ db.get_or_create_agent_context(
169
+ context_id,
170
+ workspace=workspace,
171
+ loaded_at=loaded_at,
172
+ )
173
+ if not silent:
174
+ print(f"[migrate] Created context: {context_id}")
175
+
176
+ # Update session links (silently skips if session not in DB)
177
+ if context_sessions:
178
+ for session_id in context_sessions:
179
+ db.link_session_to_agent_context(context_id, session_id)
180
+ if not silent:
181
+ print(f"[migrate] Linked {len(context_sessions)} sessions to {context_id}")
182
+
183
+ # Update event links (silently skips if event not in DB)
184
+ if context_events:
185
+ for event_id in context_events:
186
+ db.link_event_to_agent_context(context_id, event_id)
187
+ if not silent:
188
+ print(f"[migrate] Linked {len(context_events)} events to {context_id}")
189
+
190
+ migrated += 1
191
+ except Exception as e:
192
+ if not silent:
193
+ print(f"[migrate] Failed to migrate context {context_id}: {e}")
194
+
195
+ return migrated
196
+
197
+
198
+ def backup_json_files() -> bool:
199
+ """Create backup of JSON files.
200
+
201
+ Returns:
202
+ True if backup was successful or files don't exist
203
+ """
204
+ files = [
205
+ Path.home() / ".aline" / "terminal.json",
206
+ Path.home() / ".aline" / "load.json",
207
+ ]
208
+
209
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
210
+
211
+ for path in files:
212
+ if not path.exists():
213
+ continue
214
+
215
+ backup_path = path.with_suffix(f".json.bak.{timestamp}")
216
+ try:
217
+ backup_path.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
218
+ print(f"[backup] Created backup: {backup_path}")
219
+ except Exception as e:
220
+ print(f"[backup] Failed to backup {path}: {e}")
221
+ return False
222
+
223
+ return True
224
+
225
+
226
+ def main():
227
+ parser = argparse.ArgumentParser(
228
+ description="Migrate terminal.json and load.json to SQLite database"
229
+ )
230
+ parser.add_argument(
231
+ "--backup",
232
+ action="store_true",
233
+ help="Create backup of JSON files before migration",
234
+ )
235
+ parser.add_argument(
236
+ "--dry-run",
237
+ action="store_true",
238
+ help="Show what would be migrated without actually doing it",
239
+ )
240
+ parser.add_argument(
241
+ "--db-path",
242
+ type=str,
243
+ default=str(Path.home() / ".aline" / "realign.db"),
244
+ help="Path to SQLite database",
245
+ )
246
+
247
+ args = parser.parse_args()
248
+
249
+ print("=" * 60)
250
+ print("Aline Agents Migration: JSON -> SQLite")
251
+ print("=" * 60)
252
+
253
+ if args.dry_run:
254
+ print("[mode] DRY RUN - no changes will be made")
255
+
256
+ # Backup if requested
257
+ if args.backup and not args.dry_run:
258
+ print("\n[step] Creating backups...")
259
+ if not backup_json_files():
260
+ print("[error] Backup failed, aborting migration")
261
+ sys.exit(1)
262
+
263
+ # Initialize database
264
+ if not args.dry_run:
265
+ from .sqlite_db import SQLiteDatabase
266
+
267
+ print(f"\n[step] Initializing database at {args.db_path}")
268
+ db = SQLiteDatabase(args.db_path)
269
+ if not db.initialize():
270
+ print("[error] Database initialization failed")
271
+ sys.exit(1)
272
+ else:
273
+ db = None
274
+
275
+ # Migrate terminal.json
276
+ print("\n[step] Migrating terminal.json -> agents table")
277
+ agents_migrated = migrate_terminal_json(db, dry_run=args.dry_run)
278
+ print(f"[result] Migrated {agents_migrated} agents")
279
+
280
+ # Migrate load.json
281
+ print("\n[step] Migrating load.json -> agent_contexts tables")
282
+ contexts_migrated = migrate_load_json(db, dry_run=args.dry_run)
283
+ print(f"[result] Migrated {contexts_migrated} contexts")
284
+
285
+ # Cleanup
286
+ if db:
287
+ db.close()
288
+
289
+ print("\n" + "=" * 60)
290
+ print("Migration complete!")
291
+ print(f" Agents migrated: {agents_migrated}")
292
+ print(f" Contexts migrated: {contexts_migrated}")
293
+ print("=" * 60)
294
+
295
+
296
+ if __name__ == "__main__":
297
+ main()