aline-ai 0.7.3__py3-none-any.whl → 0.7.4__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.7.3.dist-info → aline_ai-0.7.4.dist-info}/METADATA +1 -1
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/RECORD +31 -26
- realign/__init__.py +1 -1
- realign/adapters/codex.py +30 -2
- realign/claude_hooks/stop_hook.py +176 -21
- realign/codex_home.py +71 -0
- realign/codex_hooks/__init__.py +16 -0
- realign/codex_hooks/notify_hook.py +511 -0
- realign/codex_hooks/notify_hook_installer.py +247 -0
- realign/commands/doctor.py +125 -0
- realign/commands/import_shares.py +30 -10
- realign/commands/init.py +16 -0
- realign/commands/sync_agent.py +230 -52
- realign/commit_pipeline.py +1024 -0
- realign/config.py +3 -11
- realign/dashboard/app.py +150 -0
- realign/dashboard/diagnostics.py +274 -0
- realign/dashboard/screens/create_agent.py +2 -1
- realign/dashboard/screens/create_agent_info.py +40 -77
- realign/dashboard/tmux_manager.py +334 -20
- realign/dashboard/widgets/agents_panel.py +812 -202
- realign/dashboard/widgets/config_panel.py +34 -121
- realign/dashboard/widgets/header.py +1 -1
- realign/db/sqlite_db.py +59 -1
- realign/logging_config.py +51 -6
- realign/watcher_core.py +742 -393
- realign/worker_core.py +206 -15
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/WHEEL +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.4.dist-info}/top_level.txt +0 -0
realign/config.py
CHANGED
|
@@ -19,7 +19,7 @@ class ReAlignConfig:
|
|
|
19
19
|
llm_provider: str = "auto" # LLM provider: "auto", "claude", or "openai"
|
|
20
20
|
auto_detect_claude: bool = True # Enable Claude Code session auto-detection
|
|
21
21
|
auto_detect_codex: bool = True # Enable Codex session auto-detection
|
|
22
|
-
auto_detect_gemini: bool =
|
|
22
|
+
auto_detect_gemini: bool = False # Enable Gemini CLI session auto-detection (disabled by default)
|
|
23
23
|
mcp_auto_commit: bool = True # Enable watcher auto-commit after each user request completes
|
|
24
24
|
enable_temp_turn_titles: bool = True # Generate temporary turn titles on user prompt submit
|
|
25
25
|
share_backend_url: str = (
|
|
@@ -36,10 +36,6 @@ class ReAlignConfig:
|
|
|
36
36
|
# Local API server port (for one-click browser import)
|
|
37
37
|
local_api_port: int = 17280
|
|
38
38
|
|
|
39
|
-
# Terminal auto-close settings
|
|
40
|
-
auto_close_stale_terminals: bool = False # Auto-close terminals inactive for 24+ hours
|
|
41
|
-
stale_terminal_hours: int = 24 # Hours of inactivity before auto-closing
|
|
42
|
-
|
|
43
39
|
@classmethod
|
|
44
40
|
def load(cls, config_path: Optional[Path] = None) -> "ReAlignConfig":
|
|
45
41
|
"""Load configuration from file with environment variable overrides."""
|
|
@@ -89,13 +85,11 @@ class ReAlignConfig:
|
|
|
89
85
|
"uid": os.getenv("REALIGN_UID"),
|
|
90
86
|
"max_catchup_sessions": os.getenv("REALIGN_MAX_CATCHUP_SESSIONS"),
|
|
91
87
|
"local_api_port": os.getenv("ALINE_LOCAL_API_PORT"),
|
|
92
|
-
"auto_close_stale_terminals": os.getenv("REALIGN_AUTO_CLOSE_STALE_TERMINALS"),
|
|
93
|
-
"stale_terminal_hours": os.getenv("REALIGN_STALE_TERMINAL_HOURS"),
|
|
94
88
|
}
|
|
95
89
|
|
|
96
90
|
for key, value in env_overrides.items():
|
|
97
91
|
if value is not None:
|
|
98
|
-
if key in ["summary_max_chars", "max_catchup_sessions", "
|
|
92
|
+
if key in ["summary_max_chars", "max_catchup_sessions", "local_api_port"]:
|
|
99
93
|
config_dict[key] = int(value)
|
|
100
94
|
elif key in [
|
|
101
95
|
"redact_on_match",
|
|
@@ -105,7 +99,6 @@ class ReAlignConfig:
|
|
|
105
99
|
"auto_detect_gemini",
|
|
106
100
|
"mcp_auto_commit",
|
|
107
101
|
"enable_temp_turn_titles",
|
|
108
|
-
"auto_close_stale_terminals",
|
|
109
102
|
]:
|
|
110
103
|
config_dict[key] = value.lower() in ("true", "1", "yes")
|
|
111
104
|
else:
|
|
@@ -144,8 +137,6 @@ class ReAlignConfig:
|
|
|
144
137
|
"uid": self.uid,
|
|
145
138
|
"max_catchup_sessions": self.max_catchup_sessions,
|
|
146
139
|
"local_api_port": self.local_api_port,
|
|
147
|
-
"auto_close_stale_terminals": self.auto_close_stale_terminals,
|
|
148
|
-
"stale_terminal_hours": self.stale_terminal_hours,
|
|
149
140
|
}
|
|
150
141
|
|
|
151
142
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
@@ -182,6 +173,7 @@ use_LLM: true # Whether to use a cloud LLM to generate summar
|
|
|
182
173
|
llm_provider: "auto" # LLM provider: "auto" (try Claude then OpenAI), "claude", or "openai"
|
|
183
174
|
auto_detect_claude: true # Automatically detect Claude Code session directory (~/.claude/projects/)
|
|
184
175
|
auto_detect_codex: true # Automatically detect Codex session files (~/.codex/sessions/)
|
|
176
|
+
auto_detect_gemini: false # Gemini auto-detect is disabled by default
|
|
185
177
|
mcp_auto_commit: true # Enable watcher to auto-commit after each user request completes
|
|
186
178
|
enable_temp_turn_titles: true # Generate temporary turn titles on user prompt submit
|
|
187
179
|
share_backend_url: "https://realign-server.vercel.app" # Backend URL for interactive share export
|
realign/dashboard/app.py
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
"""Aline Dashboard - Main Application."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import os
|
|
4
5
|
import time
|
|
5
6
|
import traceback
|
|
6
7
|
|
|
7
8
|
from textual.app import App, ComposeResult
|
|
8
9
|
from textual.binding import Binding
|
|
10
|
+
from textual.worker import Worker, WorkerState
|
|
9
11
|
from textual.widgets import Footer, TabbedContent, TabPane
|
|
10
12
|
|
|
11
13
|
from ..logging_config import setup_logger
|
|
14
|
+
from .diagnostics import DashboardDiagnostics
|
|
12
15
|
from .widgets import (
|
|
13
16
|
AlineHeader,
|
|
14
17
|
ConfigPanel,
|
|
@@ -40,6 +43,7 @@ class AlineDashboard(App):
|
|
|
40
43
|
Binding("?", "help", "Help"),
|
|
41
44
|
Binding("tab", "next_tab", "Next Tab", priority=True, show=False),
|
|
42
45
|
Binding("shift+tab", "prev_tab", "Prev Tab", priority=True, show=False),
|
|
46
|
+
Binding("ctrl+d", "dump_diagnostics", "Dump Diagnostics", show=False),
|
|
43
47
|
Binding("ctrl+c", "quit_confirm", "Quit", priority=True),
|
|
44
48
|
]
|
|
45
49
|
|
|
@@ -57,7 +61,15 @@ class AlineDashboard(App):
|
|
|
57
61
|
self.use_native_terminal = use_native_terminal
|
|
58
62
|
self._native_terminal_mode = self._detect_native_mode()
|
|
59
63
|
self._local_api_server = None
|
|
64
|
+
self._diagnostics = DashboardDiagnostics.start()
|
|
65
|
+
self._tmux_width_timer = None
|
|
66
|
+
self._diagnostics.install_global_exception_hooks()
|
|
67
|
+
self._watchdog_timer = None
|
|
60
68
|
self._apply_saved_theme()
|
|
69
|
+
self._diagnostics.event(
|
|
70
|
+
"dashboard_init",
|
|
71
|
+
native_terminal=bool(self._native_terminal_mode),
|
|
72
|
+
)
|
|
61
73
|
logger.info(f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})")
|
|
62
74
|
|
|
63
75
|
def _detect_native_mode(self) -> bool:
|
|
@@ -92,6 +104,11 @@ class AlineDashboard(App):
|
|
|
92
104
|
logger.info("on_mount() started")
|
|
93
105
|
try:
|
|
94
106
|
self._quit_confirm_deadline: float | None = None
|
|
107
|
+
try:
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
self._diagnostics.install_asyncio_exception_handler(loop)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
95
112
|
|
|
96
113
|
# Start local API server for one-click browser import
|
|
97
114
|
self._start_local_api_server()
|
|
@@ -100,11 +117,129 @@ class AlineDashboard(App):
|
|
|
100
117
|
if self._native_terminal_mode:
|
|
101
118
|
self._setup_native_terminal_layout()
|
|
102
119
|
|
|
120
|
+
# Watchdog: capture snapshots for intermittent blank/stuck UI issues.
|
|
121
|
+
if self._watchdog_timer is None:
|
|
122
|
+
self._watchdog_timer = self.set_interval(5.0, self._watchdog_check)
|
|
123
|
+
|
|
103
124
|
logger.info("on_mount() completed successfully")
|
|
104
125
|
except Exception as e:
|
|
105
126
|
logger.error(f"on_mount() failed: {e}\n{traceback.format_exc()}")
|
|
106
127
|
raise
|
|
107
128
|
|
|
129
|
+
def on_resize(self) -> None:
|
|
130
|
+
"""Keep the tmux outer layout stable when the terminal is resized."""
|
|
131
|
+
if self._native_terminal_mode:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Debounce: terminal resize can generate many events; keep the UI responsive.
|
|
135
|
+
try:
|
|
136
|
+
if self._tmux_width_timer is not None:
|
|
137
|
+
try:
|
|
138
|
+
self._tmux_width_timer.stop()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
self._tmux_width_timer = self.set_timer(0.05, self._enforce_tmux_dashboard_width)
|
|
142
|
+
except Exception:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
def _enforce_tmux_dashboard_width(self) -> None:
|
|
146
|
+
try:
|
|
147
|
+
from . import tmux_manager
|
|
148
|
+
|
|
149
|
+
tmux_manager.enforce_outer_dashboard_pane_width()
|
|
150
|
+
except Exception:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
def on_unmount(self) -> None:
|
|
154
|
+
try:
|
|
155
|
+
self._diagnostics.event("dashboard_unmount")
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
160
|
+
if event.state != WorkerState.ERROR:
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
worker = event.worker
|
|
164
|
+
err = getattr(worker, "error", None)
|
|
165
|
+
if isinstance(err, BaseException):
|
|
166
|
+
self._diagnostics.exception(
|
|
167
|
+
"worker_error",
|
|
168
|
+
err,
|
|
169
|
+
worker_name=str(getattr(worker, "name", "") or ""),
|
|
170
|
+
worker_group=str(getattr(worker, "group", "") or ""),
|
|
171
|
+
worker_state=str(event.state),
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
self._diagnostics.event(
|
|
175
|
+
"worker_error",
|
|
176
|
+
error=str(err),
|
|
177
|
+
worker_name=str(getattr(worker, "name", "") or ""),
|
|
178
|
+
worker_group=str(getattr(worker, "group", "") or ""),
|
|
179
|
+
worker_state=str(event.state),
|
|
180
|
+
)
|
|
181
|
+
except Exception:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
def _watchdog_check(self) -> None:
|
|
185
|
+
"""Lightweight watchdog to detect UI blanking and missing panes."""
|
|
186
|
+
try:
|
|
187
|
+
panel = self.query_one(AgentsPanel)
|
|
188
|
+
state = panel.diagnostics_state()
|
|
189
|
+
agents_count = int(state.get("agents_count", 0) or 0)
|
|
190
|
+
children = int(state.get("agents_list_children", 1) or 0)
|
|
191
|
+
if agents_count > 0 and children == 0:
|
|
192
|
+
self._diagnostics.snapshot(reason="agents_panel_blank", app=self, agents_panel=state)
|
|
193
|
+
panel.force_render(reason="watchdog_blank")
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# Only relevant for tmux mode.
|
|
198
|
+
if self._native_terminal_mode:
|
|
199
|
+
return
|
|
200
|
+
try:
|
|
201
|
+
from . import tmux_manager
|
|
202
|
+
|
|
203
|
+
panes = tmux_manager.list_outer_panes(timeout_s=0.2)
|
|
204
|
+
if panes and len(panes) < 2:
|
|
205
|
+
try:
|
|
206
|
+
tmux_state = tmux_manager.collect_tmux_debug_state()
|
|
207
|
+
except Exception:
|
|
208
|
+
tmux_state = {}
|
|
209
|
+
self._diagnostics.snapshot(
|
|
210
|
+
reason="tmux_outer_missing_right_pane",
|
|
211
|
+
app=self,
|
|
212
|
+
tmux_state=tmux_state,
|
|
213
|
+
)
|
|
214
|
+
try:
|
|
215
|
+
tmux_manager.ensure_right_pane_ready()
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
else:
|
|
219
|
+
pane1_cmd = ""
|
|
220
|
+
for ln in panes:
|
|
221
|
+
parts = ln.split("\t")
|
|
222
|
+
if parts and parts[0] == "1":
|
|
223
|
+
pane1_cmd = parts[2] if len(parts) > 2 else ""
|
|
224
|
+
break
|
|
225
|
+
if pane1_cmd and pane1_cmd != "tmux":
|
|
226
|
+
try:
|
|
227
|
+
tmux_state = tmux_manager.collect_tmux_debug_state()
|
|
228
|
+
except Exception:
|
|
229
|
+
tmux_state = {}
|
|
230
|
+
self._diagnostics.snapshot(
|
|
231
|
+
reason="tmux_right_pane_not_attached",
|
|
232
|
+
app=self,
|
|
233
|
+
pane1_current_command=pane1_cmd,
|
|
234
|
+
tmux_state=tmux_state,
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
tmux_manager.ensure_right_pane_ready()
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
108
243
|
def _start_local_api_server(self) -> None:
|
|
109
244
|
"""Start the local HTTP API server for browser-based agent import."""
|
|
110
245
|
try:
|
|
@@ -177,6 +312,10 @@ class AlineDashboard(App):
|
|
|
177
312
|
|
|
178
313
|
async def action_refresh(self) -> None:
|
|
179
314
|
"""Refresh the current tab."""
|
|
315
|
+
try:
|
|
316
|
+
self._diagnostics.event("action_refresh")
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
180
319
|
tabbed_content = self.query_one(TabbedContent)
|
|
181
320
|
active_tab_id = tabbed_content.active
|
|
182
321
|
|
|
@@ -191,6 +330,17 @@ class AlineDashboard(App):
|
|
|
191
330
|
|
|
192
331
|
self.push_screen(HelpScreen())
|
|
193
332
|
|
|
333
|
+
def action_dump_diagnostics(self) -> None:
|
|
334
|
+
try:
|
|
335
|
+
self._diagnostics.snapshot(reason="manual_dump", app=self)
|
|
336
|
+
path = self._diagnostics.path
|
|
337
|
+
if path:
|
|
338
|
+
self.notify(f"Diagnostics written: {path}", title="Diagnostics", timeout=4)
|
|
339
|
+
else:
|
|
340
|
+
self.notify("Diagnostics written to stderr (no log dir)", title="Diagnostics", timeout=4)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
self.notify(f"Diagnostics failed: {e}", title="Diagnostics", severity="error", timeout=4)
|
|
343
|
+
|
|
194
344
|
def action_quit_confirm(self) -> None:
|
|
195
345
|
"""Quit only when Ctrl+C is pressed twice quickly."""
|
|
196
346
|
now = _monotonic()
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Dashboard diagnostics and high-signal logging.
|
|
2
|
+
|
|
3
|
+
This module is intentionally best-effort: diagnostics should never crash the dashboard.
|
|
4
|
+
It provides a JSONL log (one JSON object per line) with correlation IDs and state
|
|
5
|
+
snapshots for debugging intermittent UI issues (blank panes, stuck refreshes, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import traceback
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..logging_config import get_log_directory
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _safe_json(obj: Any) -> str:
|
|
29
|
+
return json.dumps(obj, ensure_ascii=False, default=str, separators=(",", ":"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _now_iso() -> str:
|
|
33
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _selected_env() -> dict[str, str]:
|
|
37
|
+
allow_prefixes = ("ALINE_", "REALIGN_")
|
|
38
|
+
allow_keys = {
|
|
39
|
+
"TERM",
|
|
40
|
+
"TERM_PROGRAM",
|
|
41
|
+
"TERM_PROGRAM_VERSION",
|
|
42
|
+
"TMUX",
|
|
43
|
+
"SHELL",
|
|
44
|
+
"LANG",
|
|
45
|
+
"LC_ALL",
|
|
46
|
+
}
|
|
47
|
+
out: dict[str, str] = {}
|
|
48
|
+
for k, v in os.environ.items():
|
|
49
|
+
if any(k.startswith(p) for p in allow_prefixes) or k in allow_keys:
|
|
50
|
+
out[k] = str(v)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_DIAG_SINGLETON: "DashboardDiagnostics | None" = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def reset_dashboard_diagnostics_for_tests() -> None:
|
|
58
|
+
"""Reset the diagnostics singleton/handlers (intended for pytest)."""
|
|
59
|
+
global _DIAG_SINGLETON
|
|
60
|
+
_DIAG_SINGLETON = None
|
|
61
|
+
logger = logging.getLogger("realign.dashboard.diagnostics")
|
|
62
|
+
for h in list(logger.handlers):
|
|
63
|
+
try:
|
|
64
|
+
logger.removeHandler(h)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
try:
|
|
68
|
+
h.close()
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class DashboardDiagnostics:
|
|
75
|
+
session_id: str
|
|
76
|
+
path: Path | None
|
|
77
|
+
logger: logging.Logger
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def start(cls) -> "DashboardDiagnostics":
|
|
81
|
+
"""Create a per-run diagnostics logger (never raises)."""
|
|
82
|
+
global _DIAG_SINGLETON
|
|
83
|
+
if _DIAG_SINGLETON is not None:
|
|
84
|
+
return _DIAG_SINGLETON
|
|
85
|
+
|
|
86
|
+
session_id = uuid.uuid4().hex[:12]
|
|
87
|
+
|
|
88
|
+
logger = logging.getLogger("realign.dashboard.diagnostics")
|
|
89
|
+
logger.setLevel(logging.DEBUG)
|
|
90
|
+
logger.propagate = False
|
|
91
|
+
|
|
92
|
+
# Avoid duplicating handlers across hot reloads / repeated imports.
|
|
93
|
+
if logger.handlers:
|
|
94
|
+
diag = cls(session_id=session_id, path=None, logger=logger)
|
|
95
|
+
_DIAG_SINGLETON = diag
|
|
96
|
+
return diag
|
|
97
|
+
|
|
98
|
+
path: Path | None = None
|
|
99
|
+
handler: logging.Handler | None = None
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
root = get_log_directory()
|
|
103
|
+
diag_dir = root / "dashboard_diagnostics"
|
|
104
|
+
diag_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
106
|
+
path = diag_dir / f"dashboard_{stamp}_pid{os.getpid()}_{session_id}.jsonl"
|
|
107
|
+
handler = logging.FileHandler(path, encoding="utf-8")
|
|
108
|
+
except Exception:
|
|
109
|
+
# Fall back to stderr; still better than losing all diagnostics.
|
|
110
|
+
handler = logging.StreamHandler()
|
|
111
|
+
path = None
|
|
112
|
+
|
|
113
|
+
handler.setLevel(logging.DEBUG)
|
|
114
|
+
handler.setFormatter(logging.Formatter(fmt="%(message)s"))
|
|
115
|
+
logger.addHandler(handler)
|
|
116
|
+
|
|
117
|
+
diag = cls(session_id=session_id, path=path, logger=logger)
|
|
118
|
+
_DIAG_SINGLETON = diag
|
|
119
|
+
diag.event(
|
|
120
|
+
"dashboard_diagnostics_started",
|
|
121
|
+
pid=os.getpid(),
|
|
122
|
+
python=sys.version.split()[0],
|
|
123
|
+
platform=platform.platform(),
|
|
124
|
+
env=_selected_env(),
|
|
125
|
+
)
|
|
126
|
+
return diag
|
|
127
|
+
|
|
128
|
+
def event(self, name: str, **fields: Any) -> None:
|
|
129
|
+
payload = {
|
|
130
|
+
"ts": _now_iso(),
|
|
131
|
+
"t_monotonic": round(time.monotonic(), 6),
|
|
132
|
+
"pid": os.getpid(),
|
|
133
|
+
"thread": threading.current_thread().name,
|
|
134
|
+
"session_id": self.session_id,
|
|
135
|
+
"event": name,
|
|
136
|
+
**fields,
|
|
137
|
+
}
|
|
138
|
+
try:
|
|
139
|
+
self.logger.info(_safe_json(payload))
|
|
140
|
+
except Exception:
|
|
141
|
+
# Diagnostics must never crash the app.
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
def exception(self, name: str, exc: BaseException, **fields: Any) -> None:
|
|
145
|
+
try:
|
|
146
|
+
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
147
|
+
except Exception:
|
|
148
|
+
tb = ""
|
|
149
|
+
self.event(
|
|
150
|
+
name,
|
|
151
|
+
error_type=type(exc).__name__,
|
|
152
|
+
error=str(exc),
|
|
153
|
+
traceback=tb,
|
|
154
|
+
**fields,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def snapshot(self, *, reason: str, app: Any | None = None, **fields: Any) -> None: # noqa: ANN401
|
|
158
|
+
"""Capture a lightweight state snapshot (best-effort)."""
|
|
159
|
+
snap: dict[str, Any] = {
|
|
160
|
+
"reason": reason,
|
|
161
|
+
"fields": fields,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if app is not None:
|
|
165
|
+
snap["app"] = _snapshot_textual_app(app)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
from . import tmux_manager
|
|
169
|
+
|
|
170
|
+
snap["tmux"] = tmux_manager.collect_tmux_debug_state()
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
self.event("dashboard_snapshot", **snap)
|
|
175
|
+
|
|
176
|
+
def install_global_exception_hooks(self) -> None:
|
|
177
|
+
"""Install sys/threading exception hooks (best-effort)."""
|
|
178
|
+
try:
|
|
179
|
+
orig_sys_hook = sys.excepthook
|
|
180
|
+
|
|
181
|
+
def sys_hook(exc_type, exc, tb): # type: ignore[no-untyped-def]
|
|
182
|
+
try:
|
|
183
|
+
self.exception(
|
|
184
|
+
"unhandled_exception",
|
|
185
|
+
exc if isinstance(exc, BaseException) else Exception(str(exc)),
|
|
186
|
+
where="sys.excepthook",
|
|
187
|
+
)
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
try:
|
|
191
|
+
orig_sys_hook(exc_type, exc, tb)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
sys.excepthook = sys_hook
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
orig_thread_hook = threading.excepthook
|
|
201
|
+
|
|
202
|
+
def thread_hook(args): # type: ignore[no-untyped-def]
|
|
203
|
+
try:
|
|
204
|
+
self.exception(
|
|
205
|
+
"unhandled_exception",
|
|
206
|
+
args.exc_value,
|
|
207
|
+
where="threading.excepthook",
|
|
208
|
+
thread=str(getattr(args, "thread", None)),
|
|
209
|
+
)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
try:
|
|
213
|
+
orig_thread_hook(args)
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
threading.excepthook = thread_hook
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
def install_asyncio_exception_handler(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
222
|
+
"""Install an asyncio loop exception handler (best-effort)."""
|
|
223
|
+
|
|
224
|
+
def handler(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
225
|
+
exc = context.get("exception")
|
|
226
|
+
if isinstance(exc, BaseException):
|
|
227
|
+
self.exception(
|
|
228
|
+
"asyncio_exception",
|
|
229
|
+
exc,
|
|
230
|
+
message=str(context.get("message") or ""),
|
|
231
|
+
context={k: v for k, v in context.items() if k not in {"exception"}},
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
self.event(
|
|
235
|
+
"asyncio_exception",
|
|
236
|
+
message=str(context.get("message") or ""),
|
|
237
|
+
context={k: v for k, v in context.items()},
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
loop.set_exception_handler(handler)
|
|
242
|
+
except Exception:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _snapshot_textual_app(app: Any) -> dict[str, Any]: # noqa: ANN401
|
|
247
|
+
snap: dict[str, Any] = {}
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
snap["title"] = str(getattr(app, "TITLE", "") or "")
|
|
251
|
+
snap["screen"] = type(getattr(app, "screen", None)).__name__
|
|
252
|
+
snap["dark"] = bool(getattr(app, "dark", False))
|
|
253
|
+
snap["theme"] = str(getattr(app, "theme", "") or "")
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
# Best-effort widget checks for known failure modes.
|
|
258
|
+
try:
|
|
259
|
+
from textual.widgets import TabbedContent # imported lazily
|
|
260
|
+
|
|
261
|
+
tabbed = app.query_one(TabbedContent)
|
|
262
|
+
snap["active_tab"] = str(getattr(tabbed, "active", "") or "")
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
from textual.containers import Vertical
|
|
268
|
+
|
|
269
|
+
agents_list = app.query_one("#agents-list", Vertical)
|
|
270
|
+
snap["agents_list_children"] = int(len(getattr(agents_list, "children", []) or []))
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
return snap
|