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
|
@@ -38,15 +38,6 @@ class ConfigPanel(Static):
|
|
|
38
38
|
margin-bottom: 1;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
ConfigPanel .button-row {
|
|
42
|
-
height: 3;
|
|
43
|
-
margin-top: 1;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
ConfigPanel .button-row Button {
|
|
47
|
-
margin-right: 1;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
41
|
ConfigPanel .account-section {
|
|
51
42
|
height: 3;
|
|
52
43
|
align: left middle;
|
|
@@ -62,81 +53,36 @@ class ConfigPanel(Static):
|
|
|
62
53
|
margin-right: 1;
|
|
63
54
|
}
|
|
64
55
|
|
|
65
|
-
ConfigPanel .
|
|
56
|
+
ConfigPanel .settings-section {
|
|
66
57
|
height: auto;
|
|
67
58
|
margin-top: 2;
|
|
68
59
|
}
|
|
69
60
|
|
|
70
|
-
ConfigPanel .
|
|
61
|
+
ConfigPanel .settings-section .setting-row {
|
|
71
62
|
height: auto;
|
|
72
63
|
}
|
|
73
64
|
|
|
74
|
-
ConfigPanel .
|
|
75
|
-
width:
|
|
65
|
+
ConfigPanel .settings-section .setting-label {
|
|
66
|
+
width: 16;
|
|
76
67
|
}
|
|
77
68
|
|
|
78
|
-
ConfigPanel .
|
|
69
|
+
ConfigPanel .settings-section RadioSet {
|
|
79
70
|
width: auto;
|
|
80
71
|
height: auto;
|
|
81
72
|
layout: horizontal;
|
|
82
73
|
}
|
|
83
74
|
|
|
84
|
-
ConfigPanel .
|
|
75
|
+
ConfigPanel .settings-section RadioButton {
|
|
85
76
|
width: auto;
|
|
86
77
|
margin-right: 2;
|
|
87
78
|
}
|
|
88
79
|
|
|
89
|
-
ConfigPanel .
|
|
90
|
-
height:
|
|
91
|
-
margin-top: 2;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
ConfigPanel .terminal-settings {
|
|
95
|
-
height: auto;
|
|
96
|
-
margin-top: 2;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
ConfigPanel .terminal-settings .setting-row {
|
|
100
|
-
height: auto;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
ConfigPanel .terminal-settings .setting-label {
|
|
104
|
-
width: auto;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
ConfigPanel .terminal-settings RadioSet {
|
|
108
|
-
width: auto;
|
|
109
|
-
height: auto;
|
|
110
|
-
layout: horizontal;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
ConfigPanel .terminal-settings RadioButton {
|
|
114
|
-
width: auto;
|
|
115
|
-
margin-right: 2;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
ConfigPanel .appearance-settings {
|
|
119
|
-
height: auto;
|
|
120
|
-
margin-top: 2;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
ConfigPanel .appearance-settings .setting-row {
|
|
124
|
-
height: auto;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
ConfigPanel .appearance-settings .setting-label {
|
|
128
|
-
width: auto;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
ConfigPanel .appearance-settings RadioSet {
|
|
132
|
-
width: auto;
|
|
133
|
-
height: auto;
|
|
134
|
-
layout: horizontal;
|
|
80
|
+
ConfigPanel .settings-section .button-row {
|
|
81
|
+
height: 3;
|
|
135
82
|
}
|
|
136
83
|
|
|
137
|
-
ConfigPanel .
|
|
138
|
-
|
|
139
|
-
margin-right: 2;
|
|
84
|
+
ConfigPanel .settings-section .button-row Button {
|
|
85
|
+
margin-right: 1;
|
|
140
86
|
}
|
|
141
87
|
"""
|
|
142
88
|
|
|
@@ -146,7 +92,6 @@ class ConfigPanel(Static):
|
|
|
146
92
|
self._syncing_radio: bool = False # Flag to prevent recursive radio updates
|
|
147
93
|
self._login_in_progress: bool = False # Track login state
|
|
148
94
|
self._refresh_timer = None # Timer for auto-refresh
|
|
149
|
-
self._auto_close_stale_enabled: bool = False # Track auto-close setting
|
|
150
95
|
|
|
151
96
|
def compose(self) -> ComposeResult:
|
|
152
97
|
"""Compose the config panel layout."""
|
|
@@ -157,7 +102,7 @@ class ConfigPanel(Static):
|
|
|
157
102
|
yield Button("Login", id="auth-btn", variant="primary")
|
|
158
103
|
|
|
159
104
|
# Appearance settings section
|
|
160
|
-
with Static(classes="
|
|
105
|
+
with Static(classes="settings-section"):
|
|
161
106
|
yield Static("[bold]Appearance[/bold]", classes="section-title")
|
|
162
107
|
with Horizontal(classes="setting-row"):
|
|
163
108
|
yield Static("Theme:", classes="setting-label")
|
|
@@ -166,7 +111,7 @@ class ConfigPanel(Static):
|
|
|
166
111
|
yield RadioButton("Light", id="theme-light")
|
|
167
112
|
|
|
168
113
|
# Tmux settings section
|
|
169
|
-
with Static(classes="
|
|
114
|
+
with Static(classes="settings-section"):
|
|
170
115
|
yield Static("[bold]Tmux Settings[/bold]", classes="section-title")
|
|
171
116
|
with Horizontal(classes="setting-row"):
|
|
172
117
|
yield Static("Border resize:", classes="setting-label")
|
|
@@ -174,17 +119,8 @@ class ConfigPanel(Static):
|
|
|
174
119
|
yield RadioButton("Enabled", id="border-resize-enabled", value=True)
|
|
175
120
|
yield RadioButton("Disabled", id="border-resize-disabled")
|
|
176
121
|
|
|
177
|
-
# Terminal settings section
|
|
178
|
-
with Static(classes="terminal-settings"):
|
|
179
|
-
yield Static("[bold]Terminal Settings[/bold]", classes="section-title")
|
|
180
|
-
with Horizontal(classes="setting-row"):
|
|
181
|
-
yield Static("Auto-close stale terminals (24h):", classes="setting-label")
|
|
182
|
-
with RadioSet(id="auto-close-stale-radio"):
|
|
183
|
-
yield RadioButton("Enabled", id="auto-close-stale-enabled")
|
|
184
|
-
yield RadioButton("Disabled", id="auto-close-stale-disabled", value=True)
|
|
185
|
-
|
|
186
122
|
# Tools section
|
|
187
|
-
with Static(classes="
|
|
123
|
+
with Static(classes="settings-section"):
|
|
188
124
|
yield Static("[bold]Tools[/bold]", classes="section-title")
|
|
189
125
|
with Horizontal(classes="button-row"):
|
|
190
126
|
yield Button("Aline Doctor", id="doctor-btn", variant="default")
|
|
@@ -200,11 +136,25 @@ class ConfigPanel(Static):
|
|
|
200
136
|
# Query and set the actual tmux border resize state
|
|
201
137
|
self._sync_border_resize_radio()
|
|
202
138
|
|
|
203
|
-
|
|
204
|
-
|
|
139
|
+
def on_show(self) -> None:
|
|
140
|
+
"""Start periodic refresh when visible."""
|
|
141
|
+
if self._refresh_timer is None:
|
|
142
|
+
self._refresh_timer = self.set_interval(5.0, self._update_account_status)
|
|
143
|
+
else:
|
|
144
|
+
try:
|
|
145
|
+
self._refresh_timer.resume()
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
self._update_account_status()
|
|
205
149
|
|
|
206
|
-
|
|
207
|
-
|
|
150
|
+
def on_hide(self) -> None:
|
|
151
|
+
"""Pause periodic refresh when hidden."""
|
|
152
|
+
if self._refresh_timer is None:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
self._refresh_timer.pause()
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
208
158
|
|
|
209
159
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
210
160
|
"""Handle button clicks."""
|
|
@@ -228,9 +178,6 @@ class ConfigPanel(Static):
|
|
|
228
178
|
# Check which radio button is selected
|
|
229
179
|
enabled = event.pressed.id == "border-resize-enabled"
|
|
230
180
|
self._toggle_border_resize(enabled)
|
|
231
|
-
elif event.radio_set.id == "auto-close-stale-radio":
|
|
232
|
-
enabled = event.pressed.id == "auto-close-stale-enabled"
|
|
233
|
-
self._toggle_auto_close_stale(enabled)
|
|
234
181
|
|
|
235
182
|
def _update_account_status(self) -> None:
|
|
236
183
|
"""Update the account status display."""
|
|
@@ -379,7 +326,7 @@ class ConfigPanel(Static):
|
|
|
379
326
|
"""Query tmux state and sync the radio buttons to match."""
|
|
380
327
|
try:
|
|
381
328
|
# Check if MouseDrag1Border is bound by listing keys
|
|
382
|
-
result = _run_outer_tmux(["list-keys", "-T", "root"], capture=True)
|
|
329
|
+
result = _run_outer_tmux(["list-keys", "-T", "root"], capture=True, timeout_s=0.5)
|
|
383
330
|
output = result.stdout or ""
|
|
384
331
|
|
|
385
332
|
# If MouseDrag1Border is in the output, resize is enabled
|
|
@@ -407,53 +354,19 @@ class ConfigPanel(Static):
|
|
|
407
354
|
# Re-enable border resize by binding MouseDrag1Border to default resize behavior
|
|
408
355
|
_run_outer_tmux([
|
|
409
356
|
"bind", "-n", "MouseDrag1Border", "resize-pane", "-M"
|
|
410
|
-
])
|
|
357
|
+
], timeout_s=0.5)
|
|
411
358
|
self._border_resize_enabled = True
|
|
412
359
|
self.app.notify("Border resize enabled", title="Tmux")
|
|
413
360
|
else:
|
|
414
361
|
# Disable border resize by unbinding MouseDrag1Border
|
|
415
362
|
_run_outer_tmux([
|
|
416
363
|
"unbind", "-n", "MouseDrag1Border"
|
|
417
|
-
])
|
|
364
|
+
], timeout_s=0.5)
|
|
418
365
|
self._border_resize_enabled = False
|
|
419
366
|
self.app.notify("Border resize disabled", title="Tmux")
|
|
420
367
|
except Exception as e:
|
|
421
368
|
self.app.notify(f"Error toggling border resize: {e}", title="Tmux", severity="error")
|
|
422
369
|
|
|
423
|
-
def _sync_auto_close_stale_radio(self) -> None:
|
|
424
|
-
"""Sync radio buttons with config file setting."""
|
|
425
|
-
try:
|
|
426
|
-
config = ReAlignConfig.load()
|
|
427
|
-
is_enabled = config.auto_close_stale_terminals
|
|
428
|
-
self._auto_close_stale_enabled = is_enabled
|
|
429
|
-
|
|
430
|
-
# Update radio buttons without triggering the toggle action
|
|
431
|
-
self._syncing_radio = True
|
|
432
|
-
try:
|
|
433
|
-
if is_enabled:
|
|
434
|
-
radio = self.query_one("#auto-close-stale-enabled", RadioButton)
|
|
435
|
-
else:
|
|
436
|
-
radio = self.query_one("#auto-close-stale-disabled", RadioButton)
|
|
437
|
-
radio.value = True
|
|
438
|
-
finally:
|
|
439
|
-
self._syncing_radio = False
|
|
440
|
-
except Exception:
|
|
441
|
-
pass
|
|
442
|
-
|
|
443
|
-
def _toggle_auto_close_stale(self, enabled: bool) -> None:
|
|
444
|
-
"""Enable or disable auto-close stale terminals setting."""
|
|
445
|
-
try:
|
|
446
|
-
config = ReAlignConfig.load()
|
|
447
|
-
config.auto_close_stale_terminals = enabled
|
|
448
|
-
config.save()
|
|
449
|
-
self._auto_close_stale_enabled = enabled
|
|
450
|
-
if enabled:
|
|
451
|
-
self.app.notify("Auto-close stale terminals enabled", title="Terminal")
|
|
452
|
-
else:
|
|
453
|
-
self.app.notify("Auto-close stale terminals disabled", title="Terminal")
|
|
454
|
-
except Exception as e:
|
|
455
|
-
self.app.notify(f"Error saving setting: {e}", title="Config", severity="error")
|
|
456
|
-
|
|
457
370
|
def _handle_doctor(self) -> None:
|
|
458
371
|
"""Run aline doctor directly in background thread."""
|
|
459
372
|
self.app.notify("Running Aline Doctor...", title="Doctor")
|
|
@@ -40,5 +40,5 @@ class AlineHeader(Static):
|
|
|
40
40
|
line3 = " ███████║██║ ██║██╔██╗ ██║█████╗ "
|
|
41
41
|
line4 = " ██╔══██║██║ ██║██║╚██╗██║██╔══╝ "
|
|
42
42
|
line5 = " ██║ ██║███████╗██║██║ ╚████║███████╗"
|
|
43
|
-
info = f"[dim]v{self._version}
|
|
43
|
+
info = f"[dim]v{self._version}[/dim]"
|
|
44
44
|
return f"{line1}\n{line2}\n{line3}\n{line4}\n{line5} {info}"
|
realign/db/sqlite_db.py
CHANGED
|
@@ -958,15 +958,23 @@ class SQLiteDatabase(DatabaseInterface):
|
|
|
958
958
|
kind=excluded.kind,
|
|
959
959
|
payload=excluded.payload,
|
|
960
960
|
priority=MAX(COALESCE(jobs.priority, 0), COALESCE(excluded.priority, 0)),
|
|
961
|
+
attempts=CASE
|
|
962
|
+
WHEN jobs.status='retry' THEN 0
|
|
963
|
+
ELSE COALESCE(jobs.attempts, 0)
|
|
964
|
+
END,
|
|
961
965
|
updated_at=datetime('now'),
|
|
962
966
|
reschedule=CASE
|
|
963
967
|
WHEN jobs.status='processing' THEN 1
|
|
964
968
|
ELSE COALESCE(jobs.reschedule, 0)
|
|
965
969
|
END,
|
|
970
|
+
last_error=CASE
|
|
971
|
+
WHEN jobs.status='retry' THEN NULL
|
|
972
|
+
ELSE jobs.last_error
|
|
973
|
+
END,
|
|
966
974
|
status=CASE
|
|
967
975
|
WHEN jobs.status='processing' THEN jobs.status
|
|
968
976
|
WHEN jobs.status='queued' THEN jobs.status
|
|
969
|
-
WHEN jobs.status='retry' THEN
|
|
977
|
+
WHEN jobs.status='retry' THEN 'queued'
|
|
970
978
|
WHEN jobs.status='done' AND ? = 0 THEN jobs.status
|
|
971
979
|
ELSE 'queued'
|
|
972
980
|
END,
|
|
@@ -1074,6 +1082,56 @@ class SQLiteDatabase(DatabaseInterface):
|
|
|
1074
1082
|
requeue_done=True,
|
|
1075
1083
|
)
|
|
1076
1084
|
|
|
1085
|
+
def enqueue_session_process_job(
|
|
1086
|
+
self,
|
|
1087
|
+
*,
|
|
1088
|
+
session_file_path: Path,
|
|
1089
|
+
session_id: str | None = None,
|
|
1090
|
+
workspace_path: str | Path | None = None,
|
|
1091
|
+
session_type: str | None = None,
|
|
1092
|
+
source_event: str | None = None,
|
|
1093
|
+
no_track: bool = False,
|
|
1094
|
+
agent_id: str | None = None,
|
|
1095
|
+
terminal_id: str | None = None,
|
|
1096
|
+
priority: int = 15,
|
|
1097
|
+
) -> str:
|
|
1098
|
+
"""Enqueue a per-session processing job.
|
|
1099
|
+
|
|
1100
|
+
This is the preferred way to react to Stop hooks / file changes:
|
|
1101
|
+
- Dedupe by session_id so repeated enqueues don't pile up.
|
|
1102
|
+
- Worker will process all missing turns up to the safe boundary.
|
|
1103
|
+
"""
|
|
1104
|
+
sid = (session_id or session_file_path.stem or "").strip()
|
|
1105
|
+
if not sid:
|
|
1106
|
+
raise ValueError("session_id is required for session_process job")
|
|
1107
|
+
dedupe_key = f"session_process:{sid}"
|
|
1108
|
+
payload: Dict[str, Any] = {
|
|
1109
|
+
"session_id": sid,
|
|
1110
|
+
"session_file_path": str(session_file_path),
|
|
1111
|
+
}
|
|
1112
|
+
if workspace_path is not None:
|
|
1113
|
+
payload["workspace_path"] = str(workspace_path)
|
|
1114
|
+
if session_type:
|
|
1115
|
+
payload["session_type"] = str(session_type)
|
|
1116
|
+
if source_event:
|
|
1117
|
+
payload["source_event"] = str(source_event)
|
|
1118
|
+
if no_track:
|
|
1119
|
+
payload["no_track"] = True
|
|
1120
|
+
if agent_id:
|
|
1121
|
+
payload["agent_id"] = agent_id
|
|
1122
|
+
if terminal_id:
|
|
1123
|
+
payload["terminal_id"] = terminal_id
|
|
1124
|
+
|
|
1125
|
+
# Always requeue: session_process is an "edge triggered" event that may arrive
|
|
1126
|
+
# after new turns were appended. Even if the last job is done, re-run to catch up.
|
|
1127
|
+
return self.enqueue_job(
|
|
1128
|
+
kind="session_process",
|
|
1129
|
+
dedupe_key=dedupe_key,
|
|
1130
|
+
payload=payload,
|
|
1131
|
+
priority=int(priority),
|
|
1132
|
+
requeue_done=True,
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1077
1135
|
def enqueue_agent_description_job(self, *, agent_id: str) -> str:
|
|
1078
1136
|
dedupe_key = f"agent_desc:{agent_id}"
|
|
1079
1137
|
payload: Dict[str, Any] = {"agent_id": agent_id}
|
realign/logging_config.py
CHANGED
|
@@ -7,11 +7,15 @@ Supports file rotation, environment variable configuration, and structured loggi
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
+
import tempfile
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from logging.handlers import RotatingFileHandler
|
|
12
13
|
from typing import Optional
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
_LOG_DIR_CACHE: tuple[str | None, Path] | None = None
|
|
17
|
+
|
|
18
|
+
|
|
15
19
|
def get_log_level() -> int:
|
|
16
20
|
"""
|
|
17
21
|
Get log level from environment variable or default to INFO.
|
|
@@ -46,17 +50,57 @@ def get_log_directory() -> Path:
|
|
|
46
50
|
Returns:
|
|
47
51
|
Path: Log directory path
|
|
48
52
|
"""
|
|
53
|
+
global _LOG_DIR_CACHE
|
|
54
|
+
|
|
49
55
|
log_dir_str = os.getenv("REALIGN_LOG_DIR")
|
|
56
|
+
if _LOG_DIR_CACHE is not None and _LOG_DIR_CACHE[0] == log_dir_str:
|
|
57
|
+
return _LOG_DIR_CACHE[1]
|
|
50
58
|
|
|
59
|
+
candidates: list[Path] = []
|
|
51
60
|
if log_dir_str:
|
|
52
|
-
|
|
61
|
+
candidates.append(Path(log_dir_str).expanduser())
|
|
53
62
|
else:
|
|
54
|
-
|
|
63
|
+
candidates.append(Path.home() / ".aline" / ".logs")
|
|
55
64
|
|
|
56
|
-
#
|
|
57
|
-
|
|
65
|
+
# Fallback for restricted environments (e.g., sandboxed runners).
|
|
66
|
+
candidates.append(Path(tempfile.gettempdir()) / "aline-logs")
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
def can_write_files(directory: Path) -> bool:
|
|
69
|
+
try:
|
|
70
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
probe = directory / f".write_probe_{os.getpid()}_{os.urandom(4).hex()}"
|
|
72
|
+
probe.write_text("ok", encoding="utf-8")
|
|
73
|
+
probe.unlink(missing_ok=True)
|
|
74
|
+
return True
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
last_error: Exception | None = None
|
|
79
|
+
for candidate in candidates:
|
|
80
|
+
try:
|
|
81
|
+
if can_write_files(candidate):
|
|
82
|
+
_LOG_DIR_CACHE = (log_dir_str, candidate)
|
|
83
|
+
return candidate
|
|
84
|
+
except Exception as e:
|
|
85
|
+
last_error = e
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Last resort: current working directory (best-effort, avoids crashing).
|
|
89
|
+
try:
|
|
90
|
+
cwd = Path.cwd() / ".aline-logs"
|
|
91
|
+
if can_write_files(cwd):
|
|
92
|
+
_LOG_DIR_CACHE = (log_dir_str, cwd)
|
|
93
|
+
return cwd
|
|
94
|
+
return cwd
|
|
95
|
+
except Exception:
|
|
96
|
+
# If even this fails, return the first candidate to keep callers deterministic.
|
|
97
|
+
# The logger will fall back to stderr-only.
|
|
98
|
+
if last_error:
|
|
99
|
+
chosen = candidates[0]
|
|
100
|
+
else:
|
|
101
|
+
chosen = Path.cwd()
|
|
102
|
+
_LOG_DIR_CACHE = (log_dir_str, chosen)
|
|
103
|
+
return chosen
|
|
60
104
|
|
|
61
105
|
|
|
62
106
|
def setup_logger(
|
|
@@ -86,8 +130,9 @@ def setup_logger(
|
|
|
86
130
|
"""
|
|
87
131
|
logger = logging.getLogger(name)
|
|
88
132
|
|
|
89
|
-
# Only configure if not already configured
|
|
90
133
|
if logger.handlers:
|
|
134
|
+
# Keep logger level in sync with env var, but avoid duplicating handlers.
|
|
135
|
+
logger.setLevel(get_log_level())
|
|
91
136
|
return logger
|
|
92
137
|
|
|
93
138
|
logger.setLevel(get_log_level())
|