Skip to content

Commit 519d28f

Browse files
committed
Attach error-time screenshots to run history rows
Failed scheduler/trigger/hotkey runs now grab a screenshot into ~/.je_auto_control/artifacts/ and record the path on the row so the GUI Run History tab can open it. Clear/prune delete the files too. Capture is best-effort: if the screenshot call fails the run is still logged with a plain error.
1 parent 4b3d671 commit 519d28f

11 files changed

Lines changed: 292 additions & 16 deletions

File tree

je_auto_control/gui/language_wrapper/english.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,10 @@
278278
"rh_status_ok": "OK",
279279
"rh_status_error": "Error",
280280
"rh_status_running": "Running",
281+
"rh_col_artifact": "Artifact",
282+
"rh_open_artifact": "Open artifact",
283+
"rh_no_artifact": "Selected run has no artifact.",
284+
"rh_artifact_missing": "Artifact file no longer exists.",
281285

282286
# Accessibility Tab
283287
"a11y_app_label": "App:",

je_auto_control/gui/language_wrapper/japanese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,10 @@
277277
"rh_status_ok": "成功",
278278
"rh_status_error": "エラー",
279279
"rh_status_running": "実行中",
280+
"rh_col_artifact": "スクリーンショット",
281+
"rh_open_artifact": "スクリーンショットを開く",
282+
"rh_no_artifact": "選択した実行にスクリーンショットはありません。",
283+
"rh_artifact_missing": "スクリーンショットファイルが存在しません。",
280284

281285
# Accessibility Tab
282286
"a11y_app_label": "アプリ:",

je_auto_control/gui/language_wrapper/simplified_chinese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,10 @@
277277
"rh_status_ok": "成功",
278278
"rh_status_error": "错误",
279279
"rh_status_running": "执行中",
280+
"rh_col_artifact": "截图",
281+
"rh_open_artifact": "打开截图",
282+
"rh_no_artifact": "所选记录没有截图。",
283+
"rh_artifact_missing": "截图文件已不存在。",
280284

281285
# Accessibility Tab
282286
"a11y_app_label": "应用:",

je_auto_control/gui/language_wrapper/traditional_chinese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,10 @@
278278
"rh_status_ok": "成功",
279279
"rh_status_error": "錯誤",
280280
"rh_status_running": "執行中",
281+
"rh_col_artifact": "截圖",
282+
"rh_open_artifact": "開啟截圖",
283+
"rh_no_artifact": "所選紀錄沒有截圖。",
284+
"rh_artifact_missing": "截圖檔案已不存在。",
281285

282286
# Accessibility Tab
283287
"a11y_app_label": "應用程式:",

je_auto_control/gui/run_history_tab.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Run History tab: browse past scheduler / trigger / hotkey fires."""
22
import datetime as _dt
3+
from pathlib import Path
34
from typing import Optional
45

5-
from PySide6.QtCore import QTimer, Qt
6+
from PySide6.QtCore import QTimer, Qt, QUrl
7+
from PySide6.QtGui import QDesktopServices
68
from PySide6.QtWidgets import (
79
QAbstractItemView, QComboBox, QHBoxLayout, QHeaderView, QLabel,
810
QMessageBox, QPushButton, QTableWidget, QTableWidgetItem,
@@ -19,7 +21,7 @@
1921
default_history_store,
2022
)
2123

22-
_COLUMN_COUNT = 7
24+
_COLUMN_COUNT = 8
2325
_REFRESH_INTERVAL_MS = 2000
2426
_SOURCES = (
2527
("rh_source_all", None),
@@ -106,6 +108,7 @@ def _apply_table_headers(self) -> None:
106108
_t("rh_col_id"), _t("rh_col_source"), _t("rh_col_target"),
107109
_t("rh_col_script"), _t("rh_col_started"),
108110
_t("rh_col_duration"), _t("rh_col_status"),
111+
_t("rh_col_artifact"),
109112
])
110113

111114
def _build_layout(self) -> None:
@@ -122,6 +125,13 @@ def _build_layout(self) -> None:
122125
top.addWidget(clear_btn)
123126
root.addLayout(top)
124127
root.addWidget(self._table, stretch=1)
128+
self._table.cellDoubleClicked.connect(self._on_cell_double_clicked)
129+
open_row = QHBoxLayout()
130+
self._open_artifact_btn = self._tr(QPushButton(), "rh_open_artifact")
131+
self._open_artifact_btn.clicked.connect(self._open_selected_artifact)
132+
open_row.addWidget(self._open_artifact_btn)
133+
open_row.addStretch()
134+
root.addLayout(open_row)
125135
root.addWidget(self._count_label)
126136

127137
def _on_clear(self) -> None:
@@ -150,6 +160,7 @@ def _set_row(self, row: int, record) -> None:
150160
status_key = _STATUS_LABEL_KEYS.get(record.status, record.status)
151161
status_text = _t(status_key) if record.error_text is None \
152162
else f"{_t(status_key)}: {record.error_text}"
163+
artifact_text = record.artifact_path or "-"
153164
values = (
154165
str(record.id),
155166
record.source_type,
@@ -158,8 +169,49 @@ def _set_row(self, row: int, record) -> None:
158169
_format_time(record.started_at),
159170
_format_duration(record.duration_seconds),
160171
status_text,
172+
artifact_text,
161173
)
162174
for col, text in enumerate(values):
163175
item = QTableWidgetItem(text)
164176
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
165177
self._table.setItem(row, col, item)
178+
179+
def _selected_artifact_path(self) -> Optional[str]:
180+
row = self._table.currentRow()
181+
if row < 0:
182+
return None
183+
item = self._table.item(row, _COLUMN_COUNT - 1)
184+
if item is None:
185+
return None
186+
text = item.text()
187+
if not text or text == "-":
188+
return None
189+
return text
190+
191+
def _open_selected_artifact(self) -> None:
192+
path = self._selected_artifact_path()
193+
if path is None:
194+
QMessageBox.information(
195+
self, _t("rh_open_artifact"), _t("rh_no_artifact"),
196+
)
197+
return
198+
self._open_path(path)
199+
200+
def _on_cell_double_clicked(self, row: int, column: int) -> None:
201+
if column != _COLUMN_COUNT - 1:
202+
return
203+
item = self._table.item(row, column)
204+
if item is None:
205+
return
206+
text = item.text()
207+
if text and text != "-":
208+
self._open_path(text)
209+
210+
def _open_path(self, path: str) -> None:
211+
resolved = Path(path)
212+
if not resolved.exists():
213+
QMessageBox.warning(
214+
self, _t("rh_open_artifact"), _t("rh_artifact_missing"),
215+
)
216+
return
217+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(resolved)))

je_auto_control/utils/hotkey/hotkey_daemon.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919
from je_auto_control.utils.json.json_file import read_action_json
2020
from je_auto_control.utils.logging.logging_instance import autocontrol_logger
21+
from je_auto_control.utils.run_history.artifact_manager import (
22+
capture_error_snapshot,
23+
)
2124
from je_auto_control.utils.run_history.history_store import (
2225
SOURCE_HOTKEY, STATUS_ERROR, STATUS_OK, default_history_store,
2326
)
@@ -191,7 +194,11 @@ def _fire_binding(self, binding_id: str) -> None:
191194
autocontrol_logger.error("hotkey %s failed: %r",
192195
match.combo, error)
193196
finally:
194-
default_history_store.finish_run(run_id, status, error_text)
197+
artifact = (capture_error_snapshot(run_id)
198+
if status == STATUS_ERROR else None)
199+
default_history_store.finish_run(
200+
run_id, status, error_text, artifact_path=artifact,
201+
)
195202
match.fired += 1
196203

197204

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Capture error-time screenshots and attach them to run-history rows.
2+
3+
The snapshot is stored under ``~/.je_auto_control/artifacts/`` and its path
4+
is written back into the ``runs.artifact_path`` column so the GUI / REST
5+
surfaces can surface it alongside the failure reason.
6+
"""
7+
import time
8+
from pathlib import Path
9+
from typing import Optional
10+
11+
from je_auto_control.utils.logging.logging_instance import autocontrol_logger
12+
from je_auto_control.utils.run_history.history_store import (
13+
HistoryStore, default_history_store,
14+
)
15+
16+
_ARTIFACTS_DIRNAME = "artifacts"
17+
18+
19+
def default_artifacts_dir() -> Path:
20+
"""Return the per-user directory that holds error-time snapshots."""
21+
return Path.home() / ".je_auto_control" / _ARTIFACTS_DIRNAME
22+
23+
24+
def capture_error_snapshot(run_id: int,
25+
artifacts_dir: Optional[Path] = None,
26+
store: Optional[HistoryStore] = None,
27+
) -> Optional[str]:
28+
"""Screenshot the full screen and attach the file to ``run_id``.
29+
30+
Returns the absolute file path on success, ``None`` if the capture
31+
failed (no display, missing backend, disk error). Errors are
32+
swallowed and logged — a crashing artifact step must not mask the
33+
original failure the caller is trying to record.
34+
"""
35+
target_dir = Path(artifacts_dir) if artifacts_dir is not None \
36+
else default_artifacts_dir()
37+
target = target_dir / f"run_{int(run_id)}_{int(time.time() * 1000)}.png"
38+
try:
39+
target_dir.mkdir(parents=True, exist_ok=True)
40+
from je_auto_control.wrapper.auto_control_screen import screenshot
41+
screenshot(str(target))
42+
except (OSError, ValueError, RuntimeError) as error:
43+
autocontrol_logger.warning(
44+
"error-snapshot for run %d failed: %r", int(run_id), error,
45+
)
46+
return None
47+
if not target.exists():
48+
return None
49+
bound_store = store if store is not None else default_history_store
50+
bound_store.attach_artifact(int(run_id), str(target))
51+
return str(target)
52+
53+
54+
__all__ = ["capture_error_snapshot", "default_artifacts_dir"]

je_auto_control/utils/run_history/history_store.py

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
started_at REAL NOT NULL,
4141
finished_at REAL,
4242
status TEXT NOT NULL,
43-
error_text TEXT
43+
error_text TEXT,
44+
artifact_path TEXT
4445
);
4546
CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at DESC);
4647
CREATE INDEX IF NOT EXISTS idx_runs_source ON runs(source_type, source_id);
@@ -58,6 +59,7 @@ class RunRecord:
5859
finished_at: Optional[float]
5960
status: str
6061
error_text: Optional[str]
62+
artifact_path: Optional[str] = None
6163

6264
@property
6365
def duration_seconds(self) -> Optional[float]:
@@ -100,6 +102,17 @@ def __init__(self, path: Union[str, Path] = ":memory:") -> None:
100102
)
101103
self._conn.row_factory = sqlite3.Row
102104
self._conn.executescript(_SCHEMA)
105+
self._migrate_schema()
106+
107+
def _migrate_schema(self) -> None:
108+
"""Add columns that older store files are missing."""
109+
cols = {row["name"] for row in self._conn.execute(
110+
"PRAGMA table_info(runs)",
111+
).fetchall()}
112+
if "artifact_path" not in cols:
113+
self._conn.execute(
114+
"ALTER TABLE runs ADD COLUMN artifact_path TEXT",
115+
)
103116

104117
@property
105118
def path(self) -> str:
@@ -121,17 +134,27 @@ def start_run(self, source_type: str, source_id: str,
121134

122135
def finish_run(self, run_id: int, status: str,
123136
error_text: Optional[str] = None,
124-
finished_at: Optional[float] = None) -> bool:
137+
finished_at: Optional[float] = None,
138+
artifact_path: Optional[str] = None) -> bool:
125139
"""Update a pending run with its final status; return False if unknown."""
126140
_validate_status(status)
127141
if status == STATUS_RUNNING:
128142
raise ValueError("cannot finish a run with status=running")
129143
ts = float(finished_at) if finished_at is not None else time.time()
130144
with self._lock:
131145
cursor = self._conn.execute(
132-
"UPDATE runs SET finished_at = ?, status = ?, error_text = ?"
133-
" WHERE id = ?",
134-
(ts, status, error_text, int(run_id)),
146+
"UPDATE runs SET finished_at = ?, status = ?, error_text = ?,"
147+
" artifact_path = ? WHERE id = ?",
148+
(ts, status, error_text, artifact_path, int(run_id)),
149+
)
150+
return cursor.rowcount > 0
151+
152+
def attach_artifact(self, run_id: int, artifact_path: str) -> bool:
153+
"""Attach or replace the artifact path on a finished run."""
154+
with self._lock:
155+
cursor = self._conn.execute(
156+
"UPDATE runs SET artifact_path = ? WHERE id = ?",
157+
(artifact_path, int(run_id)),
135158
)
136159
return cursor.rowcount > 0
137160

@@ -177,23 +200,36 @@ def count(self, source_type: Optional[str] = None) -> int:
177200
return int(row[0])
178201

179202
def clear(self) -> int:
180-
"""Delete every row; return the number removed."""
203+
"""Delete every row (and its artifact file); return rows removed."""
181204
with self._lock:
205+
paths = [r[0] for r in self._conn.execute(
206+
"SELECT artifact_path FROM runs WHERE artifact_path IS NOT NULL",
207+
).fetchall()]
182208
cursor = self._conn.execute("DELETE FROM runs")
183-
return int(cursor.rowcount)
209+
removed = int(cursor.rowcount)
210+
_remove_artifact_files(paths)
211+
return removed
184212

185213
def prune(self, keep_latest: int) -> int:
186-
"""Keep only the newest ``keep_latest`` rows; return rows removed."""
214+
"""Keep only the newest ``keep_latest`` rows; delete the rest."""
187215
if keep_latest < 0:
188216
raise ValueError("keep_latest must be >= 0")
189217
with self._lock:
218+
paths = [r[0] for r in self._conn.execute(
219+
"SELECT artifact_path FROM runs WHERE artifact_path IS NOT NULL"
220+
" AND id NOT IN ("
221+
"SELECT id FROM runs ORDER BY started_at DESC LIMIT ?)",
222+
(int(keep_latest),),
223+
).fetchall()]
190224
cursor = self._conn.execute(
191225
"DELETE FROM runs WHERE id NOT IN ("
192226
"SELECT id FROM runs ORDER BY started_at DESC LIMIT ?"
193227
")",
194228
(int(keep_latest),),
195229
)
196-
return int(cursor.rowcount)
230+
removed = int(cursor.rowcount)
231+
_remove_artifact_files(paths)
232+
return removed
197233

198234
def close(self) -> None:
199235
with self._lock:
@@ -204,6 +240,7 @@ def close(self) -> None:
204240

205241

206242
def _row_to_record(row: sqlite3.Row) -> RunRecord:
243+
artifact = row["artifact_path"] if "artifact_path" in row.keys() else None
207244
return RunRecord(
208245
id=int(row["id"]),
209246
source_type=str(row["source_type"]),
@@ -215,7 +252,23 @@ def _row_to_record(row: sqlite3.Row) -> RunRecord:
215252
status=str(row["status"]),
216253
error_text=(str(row["error_text"])
217254
if row["error_text"] is not None else None),
255+
artifact_path=str(artifact) if artifact is not None else None,
218256
)
219257

220258

259+
def _remove_artifact_files(paths) -> None:
260+
"""Best-effort delete of artifact files; ignore missing entries."""
261+
for raw in paths:
262+
if not raw:
263+
continue
264+
try:
265+
Path(raw).unlink()
266+
except FileNotFoundError:
267+
continue
268+
except OSError as error:
269+
autocontrol_logger.warning(
270+
"failed to remove artifact %r: %r", raw, error,
271+
)
272+
273+
221274
default_history_store = HistoryStore(path=_default_history_path())

je_auto_control/utils/scheduler/scheduler.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
from je_auto_control.utils.json.json_file import read_action_json
1414
from je_auto_control.utils.logging.logging_instance import autocontrol_logger
15+
from je_auto_control.utils.run_history.artifact_manager import (
16+
capture_error_snapshot,
17+
)
1518
from je_auto_control.utils.run_history.history_store import (
1619
SOURCE_SCHEDULER, STATUS_ERROR, STATUS_OK, default_history_store,
1720
)
@@ -168,7 +171,11 @@ def _fire(self, job: ScheduledJob, now_mono: float, now_wall: float) -> None:
168171
autocontrol_logger.error("scheduler job %s failed: %r",
169172
job.job_id, error)
170173
finally:
171-
default_history_store.finish_run(run_id, status, error_text)
174+
artifact = (capture_error_snapshot(run_id)
175+
if status == STATUS_ERROR else None)
176+
default_history_store.finish_run(
177+
run_id, status, error_text, artifact_path=artifact,
178+
)
172179
with self._lock:
173180
live = self._jobs.get(job.job_id)
174181
if live is None:

0 commit comments

Comments
 (0)