aline-ai 0.5.11__py3-none-any.whl → 0.5.13__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.
@@ -1012,7 +1012,7 @@ def _get_imported_sessions(db, exclude_session_ids: set) -> list:
1012
1012
  "session_file": None, # No file for imported sessions
1013
1013
  "session_title": session.session_title,
1014
1014
  "session_summary": session.session_summary,
1015
- "creator_name": session.creator_name,
1015
+ "created_by": session.created_by,
1016
1016
  }
1017
1017
  )
1018
1018
 
@@ -1226,7 +1226,7 @@ def _get_session_tracking_status_batch(
1226
1226
  if record and status in ("partial", "tracked"):
1227
1227
  info["session_title"] = record.session_title
1228
1228
  info["session_summary"] = record.session_summary
1229
- info["creator_name"] = record.creator_name
1229
+ info["created_by"] = record.created_by
1230
1230
 
1231
1231
  session_infos.append(info)
1232
1232
 
@@ -1526,7 +1526,7 @@ def watcher_session_list_command(
1526
1526
  "last_activity": info["last_activity"].isoformat(),
1527
1527
  "session_title": info.get("session_title"),
1528
1528
  "session_summary": info.get("session_summary"),
1529
- "creator_name": info.get("creator_name"),
1529
+ "created_by": info.get("created_by"),
1530
1530
  "session_file": (
1531
1531
  str(info.get("session_file")) if info.get("session_file") else None
1532
1532
  ),
@@ -1622,14 +1622,12 @@ def watcher_session_list_command(
1622
1622
  title_str = info.get("session_title") or "-"
1623
1623
  title_str = title_str.strip()
1624
1624
 
1625
- # V9: Display creator (truncate if too long)
1625
+ # V18: Display created_by UID (truncate if too long)
1626
1626
  creator_display = "-"
1627
1627
  if info["status"] in ("partial", "tracked"):
1628
- creator_name = info.get("creator_name")
1629
- if creator_name:
1630
- creator_display = creator_name
1631
- if len(creator_display) > 10:
1632
- creator_display = creator_display[:10] + "..."
1628
+ created_by = info.get("created_by")
1629
+ if created_by:
1630
+ creator_display = created_by[:8] + "..."
1633
1631
 
1634
1632
  # Truncate project name
1635
1633
  project_name = info["project_name"]
@@ -1851,8 +1849,7 @@ def watcher_event_generate_command(session_selector: str, show_sessions: bool =
1851
1849
  updated_at=now,
1852
1850
  metadata={},
1853
1851
  commit_hashes=[],
1854
- creator_name=config.user_name,
1855
- creator_id=config.user_id,
1852
+ created_by=config.uid,
1856
1853
  )
1857
1854
 
1858
1855
  # Save to database
@@ -2117,7 +2114,7 @@ def watcher_event_list_command(
2117
2114
  "id": event.id,
2118
2115
  "title": event.title,
2119
2116
  "description": event.description,
2120
- "creator_name": event.creator_name,
2117
+ "created_by": event.created_by,
2121
2118
  "generated_by": generated_by,
2122
2119
  "session_count": session_count,
2123
2120
  "session_ids": session_ids,
@@ -2177,10 +2174,8 @@ def watcher_event_list_command(
2177
2174
  else:
2178
2175
  share_link_display = "[dim]-[/dim]"
2179
2176
 
2180
- # V9: Display creator (truncate if too long)
2181
- creator_display = event.creator_name or "-"
2182
- if creator_display and len(creator_display) > 12:
2183
- creator_display = creator_display[:12] + "..."
2177
+ # V18: Display created_by UID (truncate)
2178
+ creator_display = (event.created_by[:8] + "...") if event.created_by else "-"
2184
2179
 
2185
2180
  table.add_row(
2186
2181
  str(idx),
realign/config.py CHANGED
@@ -27,9 +27,9 @@ class ReAlignConfig:
27
27
  "https://realign-server.vercel.app" # Backend URL for interactive share export
28
28
  )
29
29
 
30
- # User identity (V9)
31
- user_name: str = "" # User's display name (set during init)
32
- user_id: str = "" # User's UUID (generated from MAC address)
30
+ # User identity (V9, renamed in V17: user_id -> uid)
31
+ user_name: str = "" # User's display name (set during init or login)
32
+ uid: str = "" # User's UUID (from Supabase login)
33
33
 
34
34
  # Session catch-up settings
35
35
  max_catchup_sessions: int = 3 # Max sessions to auto-import on watcher startup
@@ -92,7 +92,7 @@ class ReAlignConfig:
92
92
  "enable_temp_turn_titles": os.getenv("REALIGN_ENABLE_TEMP_TURN_TITLES"),
93
93
  "share_backend_url": os.getenv("REALIGN_SHARE_BACKEND_URL"),
94
94
  "user_name": os.getenv("REALIGN_USER_NAME"),
95
- "user_id": os.getenv("REALIGN_USER_ID"),
95
+ "uid": os.getenv("REALIGN_UID"),
96
96
  "max_catchup_sessions": os.getenv("REALIGN_MAX_CATCHUP_SESSIONS"),
97
97
  "anthropic_api_key": os.getenv("REALIGN_ANTHROPIC_API_KEY"),
98
98
  "openai_api_key": os.getenv("REALIGN_OPENAI_API_KEY"),
@@ -124,6 +124,13 @@ class ReAlignConfig:
124
124
  else:
125
125
  config_dict[key] = value
126
126
 
127
+ # Migration: user_id -> uid (V17)
128
+ if "user_id" in config_dict and "uid" not in config_dict:
129
+ config_dict["uid"] = config_dict.pop("user_id")
130
+ elif "user_id" in config_dict:
131
+ # Both exist, prefer uid, discard user_id
132
+ config_dict.pop("user_id")
133
+
127
134
  return cls(**{k: v for k, v in config_dict.items() if k in cls.__annotations__})
128
135
 
129
136
  def save(self, config_path: Optional[Path] = None):
@@ -148,7 +155,7 @@ class ReAlignConfig:
148
155
  "enable_temp_turn_titles": self.enable_temp_turn_titles,
149
156
  "share_backend_url": self.share_backend_url,
150
157
  "user_name": self.user_name,
151
- "user_id": self.user_id,
158
+ "uid": self.uid,
152
159
  "max_catchup_sessions": self.max_catchup_sessions,
153
160
  "anthropic_api_key": self.anthropic_api_key,
154
161
  "openai_api_key": self.openai_api_key,
@@ -163,30 +170,6 @@ class ReAlignConfig:
163
170
  yaml.dump(config_dict, f, default_flow_style=False, allow_unicode=True)
164
171
 
165
172
 
166
- def generate_user_id() -> str:
167
- """
168
- Generate a persistent user UUID based on MAC address.
169
-
170
- Uses uuid.getnode() to get the MAC address, then generates a UUID5
171
- using DNS namespace. If MAC address retrieval fails, falls back to
172
- a random UUID.
173
-
174
- Returns:
175
- str: User UUID as a string
176
- """
177
- import uuid
178
-
179
- try:
180
- mac = uuid.getnode()
181
- # Use MAC address with DNS namespace to generate UUID5
182
- namespace = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") # DNS namespace
183
- user_uuid = uuid.uuid5(namespace, str(mac))
184
- return str(user_uuid)
185
- except Exception:
186
- # Fallback to random UUID if MAC address retrieval fails
187
- return str(uuid.uuid4())
188
-
189
-
190
173
  def generate_random_username() -> str:
191
174
  """
192
175
  Generate a random username with format: 3 lowercase letters + 3 digits.
@@ -1,14 +1,31 @@
1
1
  """Config Panel Widget for viewing and editing configuration."""
2
2
 
3
+ import threading
4
+ import webbrowser
3
5
  from dataclasses import fields
4
6
  from pathlib import Path
5
7
  from typing import Optional
6
8
 
7
9
  from textual.app import ComposeResult
8
- from textual.containers import Horizontal
10
+ from textual.containers import Horizontal, Vertical
9
11
  from textual.widgets import Button, DataTable, Input, Static, Switch
10
12
 
11
13
  from ..tmux_manager import _run_outer_tmux, OUTER_SESSION
14
+ from ...auth import (
15
+ load_credentials,
16
+ save_credentials,
17
+ clear_credentials,
18
+ open_login_page,
19
+ is_logged_in,
20
+ get_current_user,
21
+ find_free_port,
22
+ start_callback_server,
23
+ validate_cli_token,
24
+ )
25
+ from ...config import ReAlignConfig
26
+ from ...logging_config import setup_logger
27
+
28
+ logger = setup_logger("realign.dashboard.widgets.config_panel", "dashboard.log")
12
29
 
13
30
 
14
31
  class ConfigPanel(Static):
@@ -74,6 +91,21 @@ class ConfigPanel(Static):
74
91
  ConfigPanel .tmux-settings Switch {
75
92
  width: auto;
76
93
  }
94
+
95
+ ConfigPanel .account-section {
96
+ height: auto;
97
+ margin-top: 1;
98
+ padding: 1;
99
+ border: solid $success;
100
+ }
101
+
102
+ ConfigPanel .account-section .account-status {
103
+ margin-bottom: 1;
104
+ }
105
+
106
+ ConfigPanel .account-section Button {
107
+ margin-right: 1;
108
+ }
77
109
  """
78
110
 
79
111
  def __init__(self) -> None:
@@ -83,6 +115,8 @@ class ConfigPanel(Static):
83
115
  self._selected_key: Optional[str] = None
84
116
  self._border_resize_enabled: bool = True # Track tmux border resize state
85
117
  self._syncing_switch: bool = False # Flag to prevent recursive switch updates
118
+ self._login_in_progress: bool = False # Track login state
119
+ self._refresh_timer = None # Timer for auto-refresh
86
120
 
87
121
  def compose(self) -> ComposeResult:
88
122
  """Compose the config panel layout."""
@@ -96,6 +130,14 @@ class ConfigPanel(Static):
96
130
  yield Button("Save", id="save-btn", variant="primary")
97
131
  yield Button("Reload", id="reload-btn", variant="default")
98
132
 
133
+ # Account section
134
+ with Static(classes="account-section"):
135
+ yield Static("[bold]Account[/bold]", classes="section-title")
136
+ yield Static(id="account-status", classes="account-status")
137
+ with Horizontal(classes="button-row"):
138
+ yield Button("Login", id="login-btn", variant="primary")
139
+ yield Button("Logout", id="logout-btn", variant="warning")
140
+
99
141
  # Tmux settings section
100
142
  with Static(classes="tmux-settings"):
101
143
  yield Static("[bold]Tmux Settings[/bold]", classes="section-title")
@@ -112,9 +154,15 @@ class ConfigPanel(Static):
112
154
  # Load initial data
113
155
  self.refresh_data()
114
156
 
157
+ # Update account status display
158
+ self._update_account_status()
159
+
115
160
  # Query and set the actual tmux border resize state
116
161
  self._sync_border_resize_switch()
117
162
 
163
+ # Start timer to periodically refresh account status (every 5 seconds)
164
+ self._refresh_timer = self.set_interval(5.0, self._update_account_status)
165
+
118
166
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
119
167
  """Handle row selection in the config table."""
120
168
  table = self.query_one("#config-table", DataTable)
@@ -147,7 +195,12 @@ class ConfigPanel(Static):
147
195
  self._save_config()
148
196
  elif event.button.id == "reload-btn":
149
197
  self.refresh_data()
198
+ self._update_account_status()
150
199
  self.app.notify("Configuration reloaded", title="Config")
200
+ elif event.button.id == "login-btn":
201
+ self._handle_login()
202
+ elif event.button.id == "logout-btn":
203
+ self._handle_logout()
151
204
 
152
205
  def on_switch_changed(self, event: Switch.Changed) -> None:
153
206
  """Handle switch toggle events."""
@@ -156,6 +209,129 @@ class ConfigPanel(Static):
156
209
  if event.switch.id == "border-resize-switch":
157
210
  self._toggle_border_resize(event.value)
158
211
 
212
+ def _update_account_status(self) -> None:
213
+ """Update the account status display."""
214
+ try:
215
+ status_widget = self.query_one("#account-status", Static)
216
+ login_btn = self.query_one("#login-btn", Button)
217
+ logout_btn = self.query_one("#logout-btn", Button)
218
+ except Exception:
219
+ # Widget not ready yet
220
+ return
221
+
222
+ # Don't update if login is in progress
223
+ if self._login_in_progress:
224
+ return
225
+
226
+ credentials = get_current_user()
227
+ if credentials:
228
+ status_widget.update(
229
+ f"[green]Logged in as:[/green] [bold]{credentials.email}[/bold]"
230
+ )
231
+ login_btn.disabled = True
232
+ logout_btn.disabled = False
233
+ else:
234
+ status_widget.update("[yellow]Not logged in[/yellow]")
235
+ login_btn.disabled = False
236
+ logout_btn.disabled = True
237
+
238
+ def _handle_login(self) -> None:
239
+ """Handle login button click - start login flow in background."""
240
+ if self._login_in_progress:
241
+ self.app.notify("Login already in progress...", title="Login")
242
+ return
243
+
244
+ self._login_in_progress = True
245
+
246
+ # Update UI to show login in progress
247
+ login_btn = self.query_one("#login-btn", Button)
248
+ login_btn.disabled = True
249
+ status_widget = self.query_one("#account-status", Static)
250
+ status_widget.update("[cyan]Opening browser for login...[/cyan]")
251
+
252
+ # Start login flow in background thread
253
+ def do_login():
254
+ try:
255
+ port = find_free_port()
256
+ open_login_page(callback_port=port)
257
+
258
+ # Wait for callback (up to 5 minutes)
259
+ cli_token, error = start_callback_server(port, timeout=300)
260
+
261
+ if error:
262
+ self.app.call_from_thread(
263
+ self.app.notify, f"Login failed: {error}", title="Login", severity="error"
264
+ )
265
+ self.app.call_from_thread(self._update_account_status)
266
+ return
267
+
268
+ if not cli_token:
269
+ self.app.call_from_thread(
270
+ self.app.notify, "No token received", title="Login", severity="error"
271
+ )
272
+ self.app.call_from_thread(self._update_account_status)
273
+ return
274
+
275
+ # Validate token
276
+ credentials = validate_cli_token(cli_token)
277
+ if not credentials:
278
+ self.app.call_from_thread(
279
+ self.app.notify, "Invalid token", title="Login", severity="error"
280
+ )
281
+ self.app.call_from_thread(self._update_account_status)
282
+ return
283
+
284
+ # Save credentials
285
+ if save_credentials(credentials):
286
+ # Sync Supabase uid to local config
287
+ try:
288
+ config = ReAlignConfig.load()
289
+ old_uid = config.uid
290
+ config.uid = credentials.user_id
291
+ if not config.user_name:
292
+ config.user_name = credentials.email.split("@")[0]
293
+ config.save()
294
+ logger.info(f"Synced Supabase uid to config: {credentials.user_id[:8]}...")
295
+
296
+ # V18: Upsert user info to users table
297
+ try:
298
+ from ...db import get_database
299
+ db = get_database()
300
+ db.upsert_user(config.uid, config.user_name)
301
+ except Exception as e:
302
+ logger.debug(f"Failed to upsert user to users table: {e}")
303
+ except Exception as e:
304
+ logger.warning(f"Failed to sync uid to config: {e}")
305
+
306
+ self.app.call_from_thread(
307
+ self.app.notify, f"Logged in as {credentials.email}", title="Login"
308
+ )
309
+ else:
310
+ self.app.call_from_thread(
311
+ self.app.notify, "Failed to save credentials", title="Login", severity="error"
312
+ )
313
+
314
+ self.app.call_from_thread(self._update_account_status)
315
+
316
+ finally:
317
+ self._login_in_progress = False
318
+
319
+ thread = threading.Thread(target=do_login, daemon=True)
320
+ thread.start()
321
+
322
+ self.app.notify("Complete login in browser...", title="Login")
323
+
324
+ def _handle_logout(self) -> None:
325
+ """Handle logout button click - clear credentials."""
326
+ credentials = load_credentials()
327
+ email = credentials.email if credentials else "user"
328
+
329
+ if clear_credentials():
330
+ self._update_account_status()
331
+ self.app.notify(f"Logged out: {email}", title="Account")
332
+ else:
333
+ self.app.notify("Failed to logout", title="Account", severity="error")
334
+
159
335
  def _sync_border_resize_switch(self) -> None:
160
336
  """Query tmux state and sync the switch to match."""
161
337
  try:
realign/db/base.py CHANGED
@@ -49,9 +49,9 @@ class SessionRecord:
49
49
  summary_status: Optional[str] = None
50
50
  summary_locked_until: Optional[datetime] = None
51
51
  summary_error: Optional[str] = None
52
- # V9: creator information
53
- creator_name: Optional[str] = None
54
- creator_id: Optional[str] = None
52
+ # V18: user identity
53
+ created_by: Optional[str] = None # Creator UID
54
+ shared_by: Optional[str] = None # Sharer UID (who imported this)
55
55
  temp_title: Optional[str] = None
56
56
  # V10: cached total turn count for session list performance
57
57
  total_turns: Optional[int] = None
@@ -78,9 +78,6 @@ class TurnRecord:
78
78
  git_commit_hash: Optional[str] = None
79
79
  # V12: temporary title stored in DB turns.temp_title
80
80
  temp_title: Optional[str] = None
81
- # V9: creator information
82
- creator_name: Optional[str] = None
83
- creator_id: Optional[str] = None
84
81
 
85
82
 
86
83
  @dataclass
@@ -102,9 +99,9 @@ class EventRecord:
102
99
  share_id: Optional[str] = None # V14: Share ID on server (for reuse)
103
100
  share_admin_token: Optional[str] = None # V14: Admin token for extending expiry
104
101
  share_expiry_at: Optional[datetime] = None # V14: Last known expiry timestamp
105
- # V9: creator information
106
- creator_name: Optional[str] = None
107
- creator_id: Optional[str] = None
102
+ # V18: user identity
103
+ created_by: Optional[str] = None # Creator UID
104
+ shared_by: Optional[str] = None # Sharer UID (who imported this)
108
105
 
109
106
 
110
107
  @dataclass
@@ -124,8 +121,8 @@ class AgentRecord:
124
121
  status: str = "active" # 'active', 'stopped'
125
122
  attention: Optional[str] = None # 'permission_request', 'stop', or None
126
123
  source: Optional[str] = None
127
- creator_name: Optional[str] = None
128
- creator_id: Optional[str] = None
124
+ # V18: user identity
125
+ created_by: Optional[str] = None # Creator UID
129
126
 
130
127
 
131
128
  @dataclass
@@ -143,6 +140,16 @@ class AgentContextRecord:
143
140
  event_ids: Optional[List[str]] = None
144
141
 
145
142
 
143
+ @dataclass
144
+ class UserRecord:
145
+ """Represents a user in the users table (V18)."""
146
+
147
+ uid: str
148
+ user_name: Optional[str] = None
149
+ created_at: Optional[datetime] = None
150
+ updated_at: Optional[datetime] = None
151
+
152
+
146
153
  class DatabaseInterface(ABC):
147
154
  """Abstract interface for ReAlign storage backend."""
148
155
 
@@ -420,3 +427,13 @@ class DatabaseInterface(ABC):
420
427
  def get_all_locks(self, include_expired: bool = False) -> List[LockRecord]:
421
428
  """Get all locks, optionally including expired ones."""
422
429
  pass
430
+
431
+ @abstractmethod
432
+ def upsert_user(self, uid: str, user_name: Optional[str] = None) -> None:
433
+ """Insert or update a user in the users table (V18)."""
434
+ pass
435
+
436
+ @abstractmethod
437
+ def get_user(self, uid: str) -> Optional[UserRecord]:
438
+ """Get a user by UID from the users table (V18)."""
439
+ pass
realign/db/schema.py CHANGED
@@ -62,9 +62,23 @@ Schema V15: Agents and contexts tables (replaces terminal.json and load.json).
62
62
  Schema V16: Remove FK constraints from agent_context_sessions/events.
63
63
  - Context may reference sessions/events not yet imported to DB
64
64
  - Recreate M2M tables without FK constraints on session_id/event_id
65
+
66
+ Schema V17: Rename creator_id/creator_name to uid/user_name.
67
+ - sessions.creator_id -> uid, sessions.creator_name -> user_name
68
+ - turns.creator_id -> uid, turns.creator_name -> user_name
69
+ - events.creator_id -> uid, events.creator_name -> user_name
70
+ - agents.creator_id -> uid, agents.creator_name -> user_name
71
+ - Update indexes accordingly
72
+
73
+ Schema V18: UID refactor - created_by/shared_by with users table.
74
+ - New users table: uid -> user_name mapping
75
+ - sessions/events: uid -> created_by, drop user_name, add shared_by
76
+ - turns: drop uid and user_name (inherit from session)
77
+ - agents: uid -> created_by, drop user_name
78
+ - Update indexes accordingly
65
79
  """
66
80
 
67
- SCHEMA_VERSION = 16
81
+ SCHEMA_VERSION = 18
68
82
 
69
83
  FTS_EVENTS_SCRIPTS = [
70
84
  # Full Text Search for Events
@@ -115,7 +129,7 @@ INIT_SCRIPTS = [
115
129
  metadata TEXT -- JSON metadata
116
130
  );
117
131
  """,
118
- # Sessions table (V2: decoupled from projects, V3: summary fields, V9: creator fields, V10: total_turns cache)
132
+ # Sessions table (V2: decoupled from projects, V3: summary fields, V18: created_by/shared_by, V10: total_turns cache)
119
133
  """
120
134
  CREATE TABLE IF NOT EXISTS sessions (
121
135
  id TEXT PRIMARY KEY, -- session ID (filename stem)
@@ -133,13 +147,13 @@ INIT_SCRIPTS = [
133
147
  summary_status TEXT DEFAULT 'idle', -- V7: 'idle' | 'processing' | 'completed' | 'failed'
134
148
  summary_locked_until TEXT, -- V7: lease/TTL to avoid stuck processing
135
149
  summary_error TEXT, -- V7: last error message if failed
136
- creator_name TEXT, -- V9: Username who created the session
137
- creator_id TEXT, -- V9: User UUID (based on MAC address)
150
+ created_by TEXT, -- V18: Creator UID (FK to users.uid)
151
+ shared_by TEXT, -- V18: Sharer UID (who imported this)
138
152
  total_turns INTEGER DEFAULT 0, -- V10: Cached total turn count (avoids reading files)
139
153
  total_turns_mtime REAL -- V12: File mtime when total_turns was cached (for validation)
140
154
  );
141
155
  """,
142
- # Turns table (corresponds to git commits, V9: creator fields)
156
+ # Turns table (corresponds to git commits, V18: uid/user_name removed)
143
157
  """
144
158
  CREATE TABLE IF NOT EXISTS turns (
145
159
  id TEXT PRIMARY KEY, -- UUID
@@ -158,8 +172,6 @@ INIT_SCRIPTS = [
158
172
  timestamp TEXT NOT NULL,
159
173
  created_at TEXT DEFAULT (datetime('now')),
160
174
  git_commit_hash TEXT, -- Linked git commit hash
161
- creator_name TEXT, -- V9: Username who created the turn
162
- creator_id TEXT, -- V9: User UUID
163
175
  UNIQUE(session_id, turn_number)
164
176
  );
165
177
  """,
@@ -209,12 +221,11 @@ INIT_SCRIPTS = [
209
221
  "CREATE INDEX IF NOT EXISTS idx_sessions_workspace ON sessions(workspace_path);",
210
222
  "CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(last_activity_at DESC);",
211
223
  "CREATE INDEX IF NOT EXISTS idx_sessions_type ON sessions(session_type);",
212
- "CREATE INDEX IF NOT EXISTS idx_sessions_creator ON sessions(creator_id);", # V9
224
+ "CREATE INDEX IF NOT EXISTS idx_sessions_created_by ON sessions(created_by);", # V18
213
225
  "CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id);",
214
226
  "CREATE INDEX IF NOT EXISTS idx_turns_timestamp ON turns(timestamp DESC);",
215
227
  "CREATE INDEX IF NOT EXISTS idx_turns_hash ON turns(content_hash);",
216
- "CREATE INDEX IF NOT EXISTS idx_turns_creator ON turns(creator_id);", # V9
217
- # Events table (V9: creator fields)
228
+ # Events table (V18: created_by/shared_by)
218
229
  """
219
230
  CREATE TABLE IF NOT EXISTS events (
220
231
  id TEXT PRIMARY KEY, -- UUID
@@ -233,8 +244,8 @@ INIT_SCRIPTS = [
233
244
  share_id TEXT, -- V14: Server share ID (for reuse)
234
245
  share_admin_token TEXT, -- V14: Server admin token (extend expiry)
235
246
  share_expiry_at TEXT, -- V14: Last known expiry timestamp
236
- creator_name TEXT, -- V9: Username who created the event
237
- creator_id TEXT -- V9: User UUID
247
+ created_by TEXT, -- V18: Creator UID (FK to users.uid)
248
+ shared_by TEXT -- V18: Sharer UID (who imported this)
238
249
  );
239
250
  """,
240
251
  # Event-Commit relationship (Many-to-Many)
@@ -256,7 +267,8 @@ INIT_SCRIPTS = [
256
267
  """,
257
268
  "CREATE INDEX IF NOT EXISTS idx_event_sessions_event ON event_sessions(event_id);",
258
269
  "CREATE INDEX IF NOT EXISTS idx_event_sessions_session ON event_sessions(session_id);",
259
- # Agents table (V15: replaces terminal.json)
270
+ "CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by);", # V18
271
+ # Agents table (V15: replaces terminal.json, V18: created_by)
260
272
  """
261
273
  CREATE TABLE IF NOT EXISTS agents (
262
274
  id TEXT PRIMARY KEY, -- terminal_id (UUID)
@@ -272,8 +284,7 @@ INIT_SCRIPTS = [
272
284
  source TEXT,
273
285
  created_at TEXT DEFAULT (datetime('now')),
274
286
  updated_at TEXT DEFAULT (datetime('now')),
275
- creator_name TEXT,
276
- creator_id TEXT
287
+ created_by TEXT -- V18: Creator UID (FK to users.uid)
277
288
  );
278
289
  """,
279
290
  "CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id);",
@@ -315,6 +326,15 @@ INIT_SCRIPTS = [
315
326
  """,
316
327
  "CREATE INDEX IF NOT EXISTS idx_agent_context_events_context ON agent_context_events(context_id);",
317
328
  "CREATE INDEX IF NOT EXISTS idx_agent_context_events_event ON agent_context_events(event_id);",
329
+ # Users table (V18: UID-to-user-info mapping)
330
+ """
331
+ CREATE TABLE IF NOT EXISTS users (
332
+ uid TEXT PRIMARY KEY,
333
+ user_name TEXT,
334
+ created_at TEXT DEFAULT (datetime('now')),
335
+ updated_at TEXT DEFAULT (datetime('now'))
336
+ );
337
+ """,
318
338
  *FTS_EVENTS_SCRIPTS,
319
339
  ]
320
340
 
@@ -570,6 +590,67 @@ MIGRATION_V15_TO_V16 = [
570
590
  "CREATE INDEX IF NOT EXISTS idx_agent_context_events_event ON agent_context_events(event_id);",
571
591
  ]
572
592
 
593
+ # V16 to V17: Rename creator_id/creator_name to uid/user_name
594
+ MIGRATION_V16_TO_V17 = [
595
+ # Sessions table: rename columns
596
+ "ALTER TABLE sessions RENAME COLUMN creator_id TO uid;",
597
+ "ALTER TABLE sessions RENAME COLUMN creator_name TO user_name;",
598
+ # Turns table: rename columns
599
+ "ALTER TABLE turns RENAME COLUMN creator_id TO uid;",
600
+ "ALTER TABLE turns RENAME COLUMN creator_name TO user_name;",
601
+ # Events table: rename columns
602
+ "ALTER TABLE events RENAME COLUMN creator_id TO uid;",
603
+ "ALTER TABLE events RENAME COLUMN creator_name TO user_name;",
604
+ # Agents table: rename columns
605
+ "ALTER TABLE agents RENAME COLUMN creator_id TO uid;",
606
+ "ALTER TABLE agents RENAME COLUMN creator_name TO user_name;",
607
+ # Update indexes: drop old, create new
608
+ "DROP INDEX IF EXISTS idx_sessions_creator;",
609
+ "DROP INDEX IF EXISTS idx_turns_creator;",
610
+ "DROP INDEX IF EXISTS idx_events_creator;",
611
+ "CREATE INDEX IF NOT EXISTS idx_sessions_uid ON sessions(uid);",
612
+ "CREATE INDEX IF NOT EXISTS idx_turns_uid ON turns(uid);",
613
+ "CREATE INDEX IF NOT EXISTS idx_events_uid ON events(uid);",
614
+ ]
615
+
616
+
617
+ # V17 to V18: uid/user_name → created_by/shared_by, users table, remove turns uid
618
+ MIGRATION_V17_TO_V18 = [
619
+ # 1. Create users table
620
+ """
621
+ CREATE TABLE IF NOT EXISTS users (
622
+ uid TEXT PRIMARY KEY,
623
+ user_name TEXT,
624
+ created_at TEXT DEFAULT (datetime('now')),
625
+ updated_at TEXT DEFAULT (datetime('now'))
626
+ );
627
+ """,
628
+ # 2. Extract user info from existing data into users table
629
+ "INSERT OR IGNORE INTO users (uid, user_name) SELECT DISTINCT uid, user_name FROM sessions WHERE uid IS NOT NULL AND uid != '';",
630
+ "INSERT OR IGNORE INTO users (uid, user_name) SELECT DISTINCT uid, user_name FROM events WHERE uid IS NOT NULL AND uid != '' AND uid NOT IN (SELECT uid FROM users);",
631
+ "INSERT OR IGNORE INTO users (uid, user_name) SELECT DISTINCT uid, user_name FROM turns WHERE uid IS NOT NULL AND uid != '' AND uid NOT IN (SELECT uid FROM users);",
632
+ "INSERT OR IGNORE INTO users (uid, user_name) SELECT DISTINCT uid, user_name FROM agents WHERE uid IS NOT NULL AND uid != '' AND uid NOT IN (SELECT uid FROM users);",
633
+ # 3. Sessions: uid → created_by, drop user_name, add shared_by
634
+ "DROP INDEX IF EXISTS idx_sessions_uid;",
635
+ "ALTER TABLE sessions RENAME COLUMN uid TO created_by;",
636
+ "ALTER TABLE sessions DROP COLUMN user_name;",
637
+ "ALTER TABLE sessions ADD COLUMN shared_by TEXT;",
638
+ "CREATE INDEX IF NOT EXISTS idx_sessions_created_by ON sessions(created_by);",
639
+ # 4. Events: uid → created_by, drop user_name, add shared_by
640
+ "DROP INDEX IF EXISTS idx_events_uid;",
641
+ "ALTER TABLE events RENAME COLUMN uid TO created_by;",
642
+ "ALTER TABLE events DROP COLUMN user_name;",
643
+ "ALTER TABLE events ADD COLUMN shared_by TEXT;",
644
+ "CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by);",
645
+ # 5. Turns: drop uid and user_name
646
+ "DROP INDEX IF EXISTS idx_turns_uid;",
647
+ "ALTER TABLE turns DROP COLUMN uid;",
648
+ "ALTER TABLE turns DROP COLUMN user_name;",
649
+ # 6. Agents: uid → created_by, drop user_name (no shared_by needed)
650
+ "ALTER TABLE agents RENAME COLUMN uid TO created_by;",
651
+ "ALTER TABLE agents DROP COLUMN user_name;",
652
+ ]
653
+
573
654
 
574
655
  def get_migration_scripts(from_version: int, to_version: int) -> list:
575
656
  """Get migration scripts for upgrading between versions."""
@@ -629,4 +710,10 @@ def get_migration_scripts(from_version: int, to_version: int) -> list:
629
710
  if from_version == 15:
630
711
  scripts.extend(MIGRATION_V15_TO_V16)
631
712
 
713
+ if from_version < 17 and to_version >= 17:
714
+ scripts.extend(MIGRATION_V16_TO_V17)
715
+
716
+ if from_version < 18 and to_version >= 18:
717
+ scripts.extend(MIGRATION_V17_TO_V18)
718
+
632
719
  return scripts