echoact 0.1.0__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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1193 @@
1
+ """F-69's job and service status screen, and the one place contention ends.
2
+
3
+ Everything F-69 lists is on this screen at once: the current job's request
4
+ path, owner and progress stage, the loaded model, the budget actually
5
+ applied, live usage, and the state of REST and MCP. The two actions it
6
+ names -- cancel a job, stop an integration -- are here rather than behind a
7
+ menu, because of what A.3 decided and F-50 now says outright: requests carry
8
+ no priority by path, a GUI request does not preempt a client's running job,
9
+ and the owner reclaims the single slot by cancelling. When a client holds
10
+ the slot this screen therefore says so in as many words and names the
11
+ client, in the spirit of F-70's completion notice, instead of leaving the
12
+ owner to infer it from a greyed-out button somewhere else.
13
+
14
+ Three things the screen refuses to simplify:
15
+
16
+ *Three budgets, not one.* F-78 separates the configured value from the one
17
+ in force, and N-03 separates both from what the platform actually enforces.
18
+
19
+ *Two memory numbers.* N-03 permits the figure on screen and the figure
20
+ limit decisions are made against to differ, and the supervisor reports which
21
+ source it read. The screen shows the source rather than implying there is
22
+ only one.
23
+
24
+ *The poll does not stop when the job does.* F-22 requires usage to keep
25
+ updating while idle with a model retained, which is exactly the state a warm
26
+ worker sits in between jobs (F-17).
27
+
28
+ Nothing here blocks the Qt main thread. Cancelling kills a worker and waits
29
+ up to N-22's five seconds, stopping the service joins a server thread, and
30
+ the job list is a database read; all three run off the GUI thread and come
31
+ back as signals. They share *one* worker thread rather than taking a new
32
+ one each (see :class:`_Background`), because ``Store`` holds a connection
33
+ per thread until exit and a five-second poll would otherwise leak one every
34
+ time it fired. ``run_async`` is injectable so a test can run them inline.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import queue
40
+ import threading
41
+ from collections.abc import Callable
42
+ from pathlib import Path
43
+ from typing import TYPE_CHECKING
44
+
45
+ from PySide6.QtCore import QObject, Qt, QTimer, Signal
46
+ from PySide6.QtGui import QHideEvent, QShowEvent
47
+ from PySide6.QtWidgets import (
48
+ QAbstractItemView,
49
+ QDialog,
50
+ QDialogButtonBox,
51
+ QFileDialog,
52
+ QFrame,
53
+ QGridLayout,
54
+ QHBoxLayout,
55
+ QHeaderView,
56
+ QLabel,
57
+ QPlainTextEdit,
58
+ QProgressBar,
59
+ QPushButton,
60
+ QScrollArea,
61
+ QTableWidget,
62
+ QTableWidgetItem,
63
+ QVBoxLayout,
64
+ QWidget,
65
+ )
66
+
67
+ from .. import diagnostics
68
+ from ..domain import Budget, Job, JobState, RequestPath
69
+ from ..errors import Code, EchoActError
70
+ from ..policy import REST_HOST
71
+ from ..util.logging import get_logger
72
+ from . import icons
73
+ from .controls import label, separator
74
+ from .i18n import add_korean, count, memory_size, tr
75
+ from .theme import METRICS, Palette, mono_font
76
+
77
+ if TYPE_CHECKING: # pragma: no cover - typing only
78
+ from ..app import Application
79
+ from ..engine.supervisor import WorkerUsage
80
+
81
+ log = get_logger("ui.status")
82
+
83
+ #: How often the screen re-reads usage. A second is slower than the
84
+ #: container's own sampling interval (``RESOURCE_SAMPLE_INTERVAL_S``), so
85
+ #: every tick has something new to show without polling ahead of the data.
86
+ POLL_MS = 1000
87
+
88
+ #: The job list is a database read, so it happens on a slower cadence than
89
+ #: the in-memory figures -- and off the GUI thread either way.
90
+ JOBS_EVERY_TICKS = 5
91
+
92
+ #: How many rows F-50's "review and cancel all jobs" shows before the owner
93
+ #: has to go to the library for the rest.
94
+ JOB_ROWS = 12
95
+
96
+ add_korean(
97
+ {
98
+ "Status": "상태",
99
+ "Current job": "현재 작업",
100
+ "No job is running.": "실행 중인 작업이 없습니다.",
101
+ "Request path": "요청 경로",
102
+ "Owner": "소유자",
103
+ "Stage": "단계",
104
+ "Progress": "진행",
105
+ "Started": "시작",
106
+ "Budget applied": "적용된 예산",
107
+ "This computer (you)": "이 컴퓨터(사용자)",
108
+ "Accepted": "접수됨",
109
+ "Canceling": "취소하는 중",
110
+ "{client} is holding the generation slot.": "{client}이(가) 생성 슬롯을 사용 중입니다.",
111
+ "Requests are not prioritised by path, so this job keeps the slot until it "
112
+ "finishes or you cancel it here.": "요청 경로에 따른 우선순위는 없으므로, 이 작업은 "
113
+ "끝나거나 여기서 취소할 때까지 슬롯을 차지합니다.",
114
+ "Cancel this job": "이 작업 취소",
115
+ "Cancelling…": "취소하는 중…",
116
+ "All jobs": "모든 작업",
117
+ "You can cancel any job here, including one an integration started.":
118
+ "연동 클라이언트가 시작한 작업을 포함해 여기서 모든 작업을 취소할 수 있습니다.",
119
+ "Cancel selected job": "선택한 작업 취소",
120
+ "Requested by": "요청 주체",
121
+ "When": "시각",
122
+ "No jobs yet.": "아직 작업이 없습니다.",
123
+ "Engine": "엔진",
124
+ "Loaded model": "불러온 모델",
125
+ "No model is loaded.": "불러온 모델이 없습니다.",
126
+ "Selected model": "선택한 모델",
127
+ "Sample rate": "샘플레이트",
128
+ "Execution providers": "실행 공급자",
129
+ "Load time": "불러오기 시간",
130
+ "Budget and usage": "자원 예산과 사용량",
131
+ "Configured": "설정값",
132
+ "In force for the worker": "작업자에 적용 중",
133
+ "For the running job": "실행 중인 작업",
134
+ "Enforcement": "적용 방식",
135
+ "Generation job usage": "생성 작업 사용량",
136
+ "Measured by": "측정 출처",
137
+ "{n} s ago": "{n}초 전",
138
+ "Not measured yet.": "아직 측정되지 않았습니다.",
139
+ "These figures are the generation job alone, not the whole app.":
140
+ "이 수치는 앱 전체가 아니라 생성 작업만의 사용량입니다.",
141
+ "Peak {size}.": "최고 {size}.",
142
+ "Shown here is resident memory; limit decisions use {basis}.":
143
+ "여기 표시된 값은 상주 메모리이며, 한도 판단에는 {basis}을(를) 사용합니다.",
144
+ "enforced": "강제 적용",
145
+ "monitored": "감시",
146
+ "unavailable": "적용 안 됨",
147
+ "commit charge": "커밋 메모리",
148
+ "resident memory": "상주 메모리",
149
+ "address space": "주소 공간",
150
+ "no memory limit": "메모리 한도 없음",
151
+ "Integrations": "연동",
152
+ "Stop the service": "서비스 중지",
153
+ "Start the service": "서비스 시작",
154
+ "Disable MCP": "MCP 사용 안 함",
155
+ "Enable MCP": "MCP 사용",
156
+ "listening on {host}:{port}": "{host}:{port} 에서 수신 중",
157
+ "Stopping an integration cancels the jobs it started; the app, generation and "
158
+ "playback are unaffected.": "연동을 중지하면 해당 연동이 시작한 작업이 취소됩니다. "
159
+ "앱과 생성, 재생에는 영향이 없습니다.",
160
+ "MCP is started by the MCP client, so disabling it only refuses the next "
161
+ "connection.": "MCP 서버는 MCP 클라이언트가 실행하므로, 사용 안 함으로 두면 다음 "
162
+ "연결을 거부합니다.",
163
+ "Refresh": "새로 고침",
164
+ "Export diagnostics…": "진단 정보 내보내기…",
165
+ "Diagnostic export": "진단 정보 내보내기",
166
+ "Review this before saving it. It is written to a file you choose and sent "
167
+ "nowhere.": "저장하기 전에 내용을 확인하세요. 선택한 위치의 파일로 저장되며 어디에도 "
168
+ "전송되지 않습니다.",
169
+ "It contains no document text, no audio, no credentials and no home directory "
170
+ "path.": "본문, 오디오, 자격 증명, 사용자 홈 경로는 포함되지 않습니다.",
171
+ "Save as…": "다른 이름으로 저장…",
172
+ "Saved to {name}": "{name}에 저장했습니다",
173
+ "Preparing the export…": "진단 정보를 모으는 중…",
174
+ "Text files (*.txt)": "텍스트 파일 (*.txt)",
175
+ "threads": "스레드",
176
+ "peak": "최고",
177
+ }
178
+ )
179
+
180
+ _STATE_TEXT: dict[JobState, str] = {
181
+ JobState.ACCEPTED: "Accepted",
182
+ JobState.PREPARING_MODEL: "Preparing the model",
183
+ JobState.GENERATING: "Generating",
184
+ JobState.CANCELING: "Canceling",
185
+ JobState.COMPLETE: "Complete",
186
+ JobState.FAILED: "Failed",
187
+ JobState.CANCELED: "Canceled",
188
+ JobState.INTERRUPTED: "Interrupted",
189
+ }
190
+
191
+ _PATH_TEXT: dict[RequestPath, str] = {
192
+ RequestPath.GUI: "This computer (you)",
193
+ RequestPath.REST: "REST",
194
+ RequestPath.MCP: "MCP",
195
+ }
196
+
197
+ _ENFORCEMENT_TEXT = {
198
+ "enforced": "enforced",
199
+ "monitored": "monitored",
200
+ "unavailable": "unavailable",
201
+ }
202
+
203
+ _BASIS_TEXT = {
204
+ "commit": "commit charge",
205
+ "resident": "resident memory",
206
+ "address_space": "address space",
207
+ "none": "no memory limit",
208
+ }
209
+
210
+
211
+ class _Background:
212
+ """One long-lived worker thread for everything this screen does off the
213
+ GUI thread.
214
+
215
+ A thread per call would be shorter, and is what this used to be, but the
216
+ threads are not the resource that matters. :class:`~echoact.db.Store`
217
+ keeps one SQLite connection per thread in a ``threading.local`` and
218
+ appends it to a list that only ``Store.close()`` empties, at exit. A new
219
+ thread per poll therefore meant a new connection, a new WAL handle and a
220
+ new permanent entry in that list every ``JOBS_EVERY_TICKS`` ticks -- some
221
+ thousands of them over the soak A-20 asks for, none closed and none
222
+ collectable. One thread means one connection, however long the window
223
+ stays open and whatever the screen is asked to do.
224
+
225
+ Serialising the work is the deliberate second effect. Everything queued
226
+ here is bounded -- a cancel waits out N-22's five seconds, a service stop
227
+ joins the server thread with its own timeout, a job list is one paged
228
+ read -- so a queue cannot grow without limit the way an unbounded fan-out
229
+ of threads can, and the screen's own polling stays behind whatever action
230
+ the owner asked for rather than racing it.
231
+
232
+ The thread is a daemon and is never joined: a reply that arrives after
233
+ the window has gone is emitted on the :class:`_Signals` object the task
234
+ itself holds, and Qt has already disconnected the view's slots by then.
235
+ """
236
+
237
+ def __init__(self, name: str) -> None:
238
+ self._name = name
239
+ self._queue: queue.SimpleQueue[Callable[[], None]] = queue.SimpleQueue()
240
+ self._lock = threading.Lock()
241
+ self._thread: threading.Thread | None = None
242
+
243
+ def submit(self, fn: Callable[[], None]) -> None:
244
+ with self._lock:
245
+ if self._thread is None or not self._thread.is_alive():
246
+ self._thread = threading.Thread(target=self._run, name=self._name, daemon=True)
247
+ self._thread.start()
248
+ self._queue.put(fn)
249
+
250
+ def _run(self) -> None:
251
+ while True:
252
+ fn = self._queue.get()
253
+ try:
254
+ fn()
255
+ except Exception: # noqa: BLE001 - one bad task never ends the thread
256
+ # Every task here already reports its own failure through a
257
+ # signal; this is the last resort that keeps the queue -- and
258
+ # with it the one database connection -- alive regardless.
259
+ log.exception("status background task failed")
260
+
261
+
262
+ #: The screen's single worker. Module level rather than per view so that
263
+ #: rebuilding the screen does not start a second thread holding a second
264
+ #: connection, which is the leak this class exists to prevent.
265
+ _BACKGROUND = _Background("echoact-status")
266
+
267
+
268
+ def _default_run_async(fn: Callable[[], None]) -> None:
269
+ _BACKGROUND.submit(fn)
270
+
271
+
272
+ class _Signals(QObject):
273
+ """The worker thread's way back onto the GUI thread.
274
+
275
+ A separate emitter rather than signals on the view, because a cancel
276
+ can still be inside N-22's five seconds when the window closes: the
277
+ worker holds its own reference to this object, so the emit is always
278
+ into something alive, and Qt has already disconnected the view's slots
279
+ by then, which turns a late reply into a no-op instead of a crash.
280
+ """
281
+
282
+ action_done = Signal(object) # EchoActError | None
283
+ jobs_ready = Signal(object) # tuple[_Row, ...]
284
+ export_ready = Signal(object, object) # text | None, EchoActError | None
285
+
286
+
287
+ class _Row:
288
+ """One line of the job table, read off the GUI thread."""
289
+
290
+ __slots__ = ("job_id", "path", "owner", "state", "when", "active")
291
+
292
+ def __init__(
293
+ self, job_id: str, path: str, owner: str, state: JobState, when: float, active: bool
294
+ ) -> None:
295
+ self.job_id = job_id
296
+ self.path = path
297
+ self.owner = owner
298
+ self.state = state
299
+ self.when = when
300
+ self.active = active
301
+
302
+
303
+ class StatusView(QWidget):
304
+ """One screen for F-69, wired to :class:`~echoact.app.Application`."""
305
+
306
+ changed = Signal()
307
+
308
+ def __init__(
309
+ self,
310
+ app: Application,
311
+ palette: Palette,
312
+ parent: QWidget | None = None,
313
+ *,
314
+ run_async: Callable[[Callable[[], None]], None] = _default_run_async,
315
+ ) -> None:
316
+ super().__init__(parent)
317
+ self.app = app
318
+ self.palette_tokens = palette
319
+ self._run_async = run_async
320
+ self._busy = False
321
+ self._ticks = 0
322
+ self._rows: tuple[_Row, ...] = ()
323
+ self._last_usage: WorkerUsage | None = None
324
+ #: The last text a review dialog was opened on, for a caller that
325
+ #: wants to know what was shown without reassembling it.
326
+ self.last_export: str | None = None
327
+
328
+ self._signals = _Signals()
329
+ self._signals.action_done.connect(self._on_action_done)
330
+ self._signals.jobs_ready.connect(self._on_jobs_ready)
331
+ self._signals.export_ready.connect(self._on_export_ready)
332
+
333
+ self._build()
334
+ self.refresh()
335
+
336
+ self._timer = QTimer(self)
337
+ self._timer.setInterval(POLL_MS)
338
+ self._timer.timeout.connect(self._tick)
339
+ self._timer.start()
340
+
341
+ # ------------------------------------------------------------------
342
+ # Construction
343
+ # ------------------------------------------------------------------
344
+
345
+ def _build(self) -> None:
346
+ m = METRICS
347
+ outer = QVBoxLayout(self)
348
+ outer.setContentsMargins(0, 0, 0, 0)
349
+ outer.setSpacing(m.gap)
350
+
351
+ head = QHBoxLayout()
352
+ head.setSpacing(m.gap)
353
+ head.addWidget(label(tr("Status"), "title"))
354
+ head.addStretch(1)
355
+ self.refresh_button = QPushButton(tr("Refresh"))
356
+ self.refresh_button.setProperty("variant", "quiet")
357
+ self.refresh_button.setAccessibleName(tr("Refresh"))
358
+ self.export_button = QPushButton(tr("Export diagnostics…"))
359
+ self.export_button.setProperty("variant", "quiet")
360
+ self.export_button.setAccessibleName(tr("Export diagnostics…"))
361
+ head.addWidget(self.refresh_button)
362
+ head.addWidget(self.export_button)
363
+ outer.addLayout(head)
364
+
365
+ self.message = label("", "warn")
366
+ self.message.setWordWrap(True)
367
+ self.message.hide()
368
+ outer.addWidget(self.message)
369
+
370
+ body = QWidget()
371
+ column = QVBoxLayout(body)
372
+ column.setContentsMargins(0, 0, m.gap, 0)
373
+ column.setSpacing(m.gap_wide)
374
+ column.addWidget(self._job_card())
375
+ column.addWidget(self._table_card())
376
+ column.addWidget(self._engine_card())
377
+ column.addWidget(self._budget_card())
378
+ column.addWidget(self._service_card())
379
+ column.addStretch(1)
380
+
381
+ # N-30: at 1280x720 with 200% scaling this column is taller than the
382
+ # window, and every part of it has to stay reachable.
383
+ scroll = QScrollArea()
384
+ scroll.setWidget(body)
385
+ scroll.setWidgetResizable(True)
386
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
387
+ outer.addWidget(scroll, 1)
388
+
389
+ self.refresh_button.clicked.connect(self.refresh)
390
+ self.export_button.clicked.connect(self.export_diagnostics)
391
+ self.cancel_button.clicked.connect(self._cancel_current)
392
+ self.cancel_selected.clicked.connect(self._cancel_selected)
393
+ self.jobs.itemSelectionChanged.connect(self._update_enabled)
394
+ self.rest_button.clicked.connect(self._toggle_rest)
395
+ self.mcp_button.clicked.connect(self._toggle_mcp)
396
+
397
+ def _card(self, title: str) -> tuple[QFrame, QVBoxLayout]:
398
+ m = METRICS
399
+ card = QFrame()
400
+ card.setObjectName("Panel")
401
+ box = QVBoxLayout(card)
402
+ box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
403
+ box.setSpacing(m.gap)
404
+ box.addWidget(label(title, "section"))
405
+ return card, box
406
+
407
+ @staticmethod
408
+ def _grid() -> QGridLayout:
409
+ g = QGridLayout()
410
+ g.setContentsMargins(0, 0, 0, 0)
411
+ g.setHorizontalSpacing(METRICS.pad)
412
+ g.setVerticalSpacing(METRICS.gap_tight)
413
+ g.setColumnStretch(1, 1)
414
+ return g
415
+
416
+ def _field(self, grid: QGridLayout, row: int, name: str) -> QLabel:
417
+ """One ``name: value`` pair. The name is a label, not a placeholder
418
+ inside the value, so a screen reader reads the pair (N-30)."""
419
+ title = label(name, "secondary")
420
+ value = label("—")
421
+ value.setWordWrap(True)
422
+ value.setAccessibleName(name)
423
+ value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
424
+ grid.addWidget(title, row, 0, Qt.AlignmentFlag.AlignTop)
425
+ grid.addWidget(value, row, 1)
426
+ return value
427
+
428
+ def _job_card(self) -> QFrame:
429
+ card, box = self._card(tr("Current job"))
430
+
431
+ self.contention = label("", "warn")
432
+ self.contention.setWordWrap(True)
433
+ self.contention.hide()
434
+ box.addWidget(self.contention)
435
+
436
+ grid = self._grid()
437
+ self.job_path = self._field(grid, 0, tr("Request path"))
438
+ self.job_owner = self._field(grid, 1, tr("Owner"))
439
+ self.job_stage = self._field(grid, 2, tr("Stage"))
440
+ self.job_started = self._field(grid, 3, tr("Started"))
441
+ self.job_model = self._field(grid, 4, tr("Model"))
442
+ self.job_budget = self._field(grid, 5, tr("Budget applied"))
443
+ box.addLayout(grid)
444
+
445
+ self.progress = QProgressBar()
446
+ self.progress.setRange(0, 100)
447
+ self.progress.setValue(0)
448
+ self.progress.setAccessibleName(tr("Progress"))
449
+ box.addWidget(self.progress)
450
+ self.progress_text = label("", "muted")
451
+ box.addWidget(self.progress_text)
452
+
453
+ row = QHBoxLayout()
454
+ row.addStretch(1)
455
+ self.cancel_button = QPushButton(tr("Cancel this job"))
456
+ self.cancel_button.setProperty("variant", "danger")
457
+ self.cancel_button.setAccessibleName(tr("Cancel this job"))
458
+ row.addWidget(self.cancel_button)
459
+ box.addLayout(row)
460
+ return card
461
+
462
+ def _table_card(self) -> QFrame:
463
+ card, box = self._card(tr("All jobs"))
464
+ note = label(
465
+ tr("You can cancel any job here, including one an integration started."), "muted"
466
+ )
467
+ note.setWordWrap(True)
468
+ box.addWidget(note)
469
+
470
+ self.jobs = QTableWidget(0, 4)
471
+ self.jobs.setHorizontalHeaderLabels(
472
+ [tr("When"), tr("Requested by"), tr("Owner"), tr("Stage")]
473
+ )
474
+ self.jobs.setAccessibleName(tr("All jobs"))
475
+ self.jobs.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
476
+ self.jobs.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
477
+ self.jobs.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
478
+ self.jobs.setAlternatingRowColors(True)
479
+ self.jobs.verticalHeader().setVisible(False)
480
+ self.jobs.horizontalHeader().setSectionResizeMode(
481
+ QHeaderView.ResizeMode.ResizeToContents
482
+ )
483
+ self.jobs.horizontalHeader().setStretchLastSection(True)
484
+ self.jobs.setMinimumHeight(160)
485
+ box.addWidget(self.jobs)
486
+
487
+ row = QHBoxLayout()
488
+ self.jobs_empty = label(tr("No jobs yet."), "muted")
489
+ row.addWidget(self.jobs_empty)
490
+ row.addStretch(1)
491
+ self.cancel_selected = QPushButton(tr("Cancel selected job"))
492
+ self.cancel_selected.setProperty("variant", "danger")
493
+ self.cancel_selected.setAccessibleName(tr("Cancel selected job"))
494
+ row.addWidget(self.cancel_selected)
495
+ box.addLayout(row)
496
+ return card
497
+
498
+ def _engine_card(self) -> QFrame:
499
+ card, box = self._card(tr("Engine"))
500
+ grid = self._grid()
501
+ self.model_name = self._field(grid, 0, tr("Loaded model"))
502
+ self.model_rate = self._field(grid, 1, tr("Sample rate"))
503
+ self.model_providers = self._field(grid, 2, tr("Execution providers"))
504
+ self.model_load = self._field(grid, 3, tr("Load time"))
505
+ self.model_selected = self._field(grid, 4, tr("Selected model"))
506
+ box.addLayout(grid)
507
+ return card
508
+
509
+ def _budget_card(self) -> QFrame:
510
+ card, box = self._card(tr("Budget and usage"))
511
+ grid = self._grid()
512
+ self.budget_configured = self._field(grid, 0, tr("Configured"))
513
+ self.budget_worker = self._field(grid, 1, tr("In force for the worker"))
514
+ self.budget_job = self._field(grid, 2, tr("For the running job"))
515
+ self.budget_enforcement = self._field(grid, 3, tr("Enforcement"))
516
+ box.addLayout(grid)
517
+ box.addWidget(separator())
518
+ usage = self._grid()
519
+ self.usage_value = self._field(usage, 0, tr("Generation job usage"))
520
+ self.usage_source = self._field(usage, 1, tr("Measured by"))
521
+ box.addLayout(usage)
522
+ self.usage_note = label("", "muted")
523
+ self.usage_note.setWordWrap(True)
524
+ box.addWidget(self.usage_note)
525
+ return card
526
+
527
+ def _service_card(self) -> QFrame:
528
+ card, box = self._card(tr("Integrations"))
529
+ grid = self._grid()
530
+ self.rest_state = self._field(grid, 0, tr("Local service"))
531
+ self.mcp_state = self._field(grid, 1, "MCP")
532
+ box.addLayout(grid)
533
+
534
+ row = QHBoxLayout()
535
+ row.setSpacing(METRICS.gap)
536
+ row.addStretch(1)
537
+ self.rest_button = QPushButton(tr("Stop the service"))
538
+ self.rest_button.setProperty("variant", "quiet")
539
+ self.mcp_button = QPushButton(tr("Disable MCP"))
540
+ self.mcp_button.setProperty("variant", "quiet")
541
+ row.addWidget(self.rest_button)
542
+ row.addWidget(self.mcp_button)
543
+ box.addLayout(row)
544
+
545
+ note = label(
546
+ tr(
547
+ "Stopping an integration cancels the jobs it started; the app, generation "
548
+ "and playback are unaffected."
549
+ ),
550
+ "muted",
551
+ )
552
+ note.setWordWrap(True)
553
+ box.addWidget(note)
554
+ mcp_note = label(
555
+ tr("MCP is started by the MCP client, so disabling it only refuses the next "
556
+ "connection."),
557
+ "muted",
558
+ )
559
+ mcp_note.setWordWrap(True)
560
+ box.addWidget(mcp_note)
561
+ return card
562
+
563
+ # ------------------------------------------------------------------
564
+ # Refresh
565
+ # ------------------------------------------------------------------
566
+
567
+ def _tick(self) -> None:
568
+ self._ticks += 1
569
+ self._refresh_job()
570
+ self._refresh_usage()
571
+ if self._ticks % JOBS_EVERY_TICKS == 0:
572
+ self._load_jobs()
573
+
574
+ def refresh(self) -> None:
575
+ """Re-read everything, including the parts that cost a query.
576
+
577
+ ``_refresh_usage`` refreshes the budget card as well, because the
578
+ commit peak it reports belongs beside the enforcement basis and
579
+ both have to change in the same paint.
580
+ """
581
+ self._refresh_job()
582
+ self._refresh_engine()
583
+ self._refresh_usage()
584
+ self._refresh_service()
585
+ self._load_jobs()
586
+
587
+ def _refresh_job(self) -> None:
588
+ job = self._current_job()
589
+ if job is None:
590
+ self.job_path.setText("—")
591
+ self.job_owner.setText("—")
592
+ self.job_stage.setText(tr("No job is running."))
593
+ self.job_started.setText("—")
594
+ self.job_model.setText("—")
595
+ self.job_budget.setText("—")
596
+ self.progress.setValue(0)
597
+ self.progress_text.setText("")
598
+ self.contention.hide()
599
+ self._update_enabled()
600
+ return
601
+
602
+ self.job_path.setText(tr(_PATH_TEXT.get(job.request_path, str(job.request_path))))
603
+ self.job_owner.setText(self._owner_text(job))
604
+ self.job_stage.setText(tr(_STATE_TEXT.get(job.state, str(job.state))))
605
+ self.job_started.setText(_clock(job.started_at or job.created_at))
606
+ self.job_model.setText(job.settings.model_id)
607
+ self.job_budget.setText(_budget_text(job.budget))
608
+ self.progress.setValue(int(round(job.progress * 100)))
609
+ self.progress_text.setText(
610
+ f"{count(job.generated_segments)} / {count(job.total_segments)}"
611
+ if job.total_segments
612
+ else ""
613
+ )
614
+ self._show_contention(job)
615
+ self._update_enabled()
616
+
617
+ def _show_contention(self, job: Job) -> None:
618
+ """A.3 and F-50, said plainly rather than implied.
619
+
620
+ The owner cannot preempt a client's job and there is one slot, so
621
+ the only useful thing this screen can tell them is who has it and
622
+ that cancelling is the way to take it back.
623
+ """
624
+ if job.request_path is RequestPath.GUI or job.state.is_terminal:
625
+ self.contention.hide()
626
+ return
627
+ who = job.client_label or job.owner_client_id or tr(_PATH_TEXT[job.request_path])
628
+ self.contention.setText(
629
+ tr("{client} is holding the generation slot.").format(client=who)
630
+ + " "
631
+ + tr(
632
+ "Requests are not prioritised by path, so this job keeps the slot until it "
633
+ "finishes or you cancel it here."
634
+ )
635
+ )
636
+ self.contention.show()
637
+
638
+ def _refresh_engine(self) -> None:
639
+ loaded = None
640
+ try:
641
+ loaded = self.app.supervisor.loaded_model
642
+ except Exception: # noqa: BLE001 - a status read never breaks the screen
643
+ loaded = None
644
+ if loaded is None:
645
+ self.model_name.setText(tr("No model is loaded."))
646
+ self.model_rate.setText("—")
647
+ self.model_providers.setText("—")
648
+ self.model_load.setText("—")
649
+ else:
650
+ self.model_name.setText(loaded.model_id)
651
+ self.model_rate.setText(f"{count(loaded.sample_rate)} Hz")
652
+ self.model_providers.setText(", ".join(loaded.providers) or "—")
653
+ self.model_load.setText(f"{loaded.load_seconds:.2f} s")
654
+ self.model_selected.setText(self.app.settings.voice.model_id)
655
+
656
+ def _refresh_budget(self) -> None:
657
+ state = diagnostics.collect_budget(self.app)
658
+ self.budget_configured.setText(state.configured_error or _budget_text(state.configured))
659
+ self.budget_worker.setText(_budget_text(state.in_force))
660
+ self.budget_job.setText(_budget_text(state.running_job))
661
+ memory = tr(_ENFORCEMENT_TEXT.get(state.memory_enforcement, state.memory_enforcement))
662
+ cpu = tr(_ENFORCEMENT_TEXT.get(state.cpu_enforcement, state.cpu_enforcement))
663
+ facility = state.facility or "—"
664
+ self.budget_enforcement.setText(
665
+ f"{tr('Memory')} {memory} · CPU {cpu} · {facility}"
666
+ )
667
+ basis = tr(_BASIS_TEXT.get(state.memory_basis, state.memory_basis))
668
+ note = tr(
669
+ "Shown here is resident memory; limit decisions use {basis}."
670
+ ).format(basis=basis)
671
+ usage = self._last_usage
672
+ if usage is not None and usage.peak_commit_bytes:
673
+ note += " " + tr("Peak {size}.").format(size=memory_size(usage.peak_commit_bytes))
674
+ self.usage_note.setText(
675
+ tr("These figures are the generation job alone, not the whole app.")
676
+ + " "
677
+ + note
678
+ )
679
+
680
+ def _refresh_usage(self) -> None:
681
+ """F-22, N-21 and N-03 in two lines.
682
+
683
+ ``sample_usage`` rather than ``usage``: the supervisor offers it for
684
+ exactly this caller, and forcing the sample means the figure on
685
+ screen is this second's rather than the monitor thread's last one.
686
+
687
+ The peak beside the current figure is the peak of the *same*
688
+ quantity. Putting a peak commit charge next to a resident-set
689
+ figure would read as one number's history when it is another's,
690
+ which is the conflation N-03 exists to prevent; the commit peak
691
+ goes in the note, next to the sentence that says what it is for.
692
+ """
693
+ usage = None
694
+ try:
695
+ usage = self.app.supervisor.sample_usage()
696
+ except Exception: # noqa: BLE001
697
+ usage = None
698
+ self._last_usage = usage
699
+ if usage is None:
700
+ self.usage_value.setText(tr("Not measured yet."))
701
+ self.usage_source.setText("—")
702
+ self._refresh_budget()
703
+ return
704
+ self.usage_value.setText(
705
+ f"CPU {usage.cpu_percent:.0f}% · {tr('Memory')} {memory_size(usage.rss_bytes)}"
706
+ f" · {tr('peak')} {memory_size(usage.peak_rss_bytes)}"
707
+ )
708
+ self.usage_source.setText(
709
+ f"{usage.source} · " + tr("{n} s ago").format(n=f"{usage.age_s:.0f}")
710
+ )
711
+ self._refresh_budget()
712
+
713
+ def _service_running(self) -> bool:
714
+ """Whether a listener is actually up, read once for both users.
715
+
716
+ The label and the button's action have to come from the same
717
+ reading, or N-09's "the current state is displayed identifiably"
718
+ fails in the worst way: a control that says one thing and does
719
+ another.
720
+ """
721
+ try:
722
+ return bool(self.app.service_running)
723
+ except Exception: # noqa: BLE001
724
+ return False
725
+
726
+ def _refresh_service(self) -> None:
727
+ s = self.app.settings
728
+ running = self._service_running()
729
+ if running:
730
+ self.rest_state.setText(
731
+ tr("listening on {host}:{port}").format(host=REST_HOST, port=s.rest_port)
732
+ )
733
+ elif s.rest_enabled:
734
+ # F-79: enabled but not listening is the port-conflict case, and
735
+ # it is a notice about the integrations, never about the app.
736
+ self.rest_state.setText(tr("Integrations unavailable"))
737
+ else:
738
+ self.rest_state.setText(tr("off"))
739
+ # The button follows the listener, not the setting. In F-79's state
740
+ # -- enabled, nothing listening -- the useful action is another go at
741
+ # the port, so it offers to start; offering to stop there would turn
742
+ # the integration off on the way to a retry the owner has to ask for
743
+ # twice.
744
+ self.rest_button.setText(
745
+ tr("Stop the service") if running else tr("Start the service")
746
+ )
747
+ self.rest_button.setAccessibleName(self.rest_button.text())
748
+
749
+ self.mcp_state.setText(tr("on") if s.mcp_enabled else tr("off"))
750
+ self.mcp_button.setText(tr("Disable MCP") if s.mcp_enabled else tr("Enable MCP"))
751
+ self.mcp_button.setAccessibleName(self.mcp_button.text())
752
+
753
+ def _update_enabled(self) -> None:
754
+ """N-09: an action that cannot be taken looks like it cannot."""
755
+ job = self._current_job()
756
+ self.cancel_button.setEnabled(
757
+ not self._busy and job is not None and not job.state.is_terminal
758
+ )
759
+ row = self._selected_row()
760
+ self.cancel_selected.setEnabled(not self._busy and row is not None and row.active)
761
+ self.rest_button.setEnabled(not self._busy)
762
+ self.mcp_button.setEnabled(not self._busy)
763
+ self.export_button.setEnabled(not self._busy)
764
+
765
+ def _current_job(self) -> Job | None:
766
+ try:
767
+ return self.app.engine.current()
768
+ except Exception: # noqa: BLE001
769
+ return None
770
+
771
+ def _owner_text(self, job: Job) -> str:
772
+ if job.request_path is RequestPath.GUI:
773
+ return tr("This computer (you)")
774
+ return job.client_label or job.owner_client_id or "—"
775
+
776
+ # ------------------------------------------------------------------
777
+ # The job table (F-50)
778
+ # ------------------------------------------------------------------
779
+
780
+ def _load_jobs(self) -> None:
781
+ signals = self._signals
782
+
783
+ def work() -> None:
784
+ rows: list[_Row] = []
785
+ try:
786
+ page = self.app.store.list_jobs(limit=JOB_ROWS)
787
+ for item in page.items:
788
+ rows.append(
789
+ _Row(
790
+ job_id=item.job_id,
791
+ path=str(item.request_path),
792
+ owner=item.client_label or item.owner_client_id,
793
+ state=item.state,
794
+ when=item.ended_at or item.started_at or item.created_at,
795
+ active=item.state.is_active,
796
+ )
797
+ )
798
+ except EchoActError:
799
+ rows = []
800
+ except Exception: # noqa: BLE001 - the screen survives a bad read
801
+ rows = []
802
+ signals.jobs_ready.emit(tuple(rows))
803
+
804
+ self._run_async(work)
805
+
806
+ def _on_jobs_ready(self, rows: tuple[_Row, ...]) -> None:
807
+ self._rows = rows
808
+ selected = self._selected_row()
809
+ keep = selected.job_id if selected else None
810
+ self.jobs.setRowCount(len(rows))
811
+ for i, row in enumerate(rows):
812
+ path = tr(_PATH_TEXT.get(RequestPath(row.path), row.path)) if row.path else "—"
813
+ values = (
814
+ _clock(row.when),
815
+ path,
816
+ row.owner or "—",
817
+ tr(_STATE_TEXT.get(row.state, str(row.state))),
818
+ )
819
+ for column, text in enumerate(values):
820
+ item = QTableWidgetItem(text)
821
+ item.setData(Qt.ItemDataRole.UserRole, row.job_id)
822
+ self.jobs.setItem(i, column, item)
823
+ if row.job_id == keep:
824
+ self.jobs.selectRow(i)
825
+ self.jobs_empty.setVisible(not rows)
826
+ self._update_enabled()
827
+
828
+ def _selected_row(self) -> _Row | None:
829
+ items = self.jobs.selectedItems()
830
+ if not items:
831
+ return None
832
+ job_id = items[0].data(Qt.ItemDataRole.UserRole)
833
+ for row in self._rows:
834
+ if row.job_id == job_id:
835
+ return row
836
+ return None
837
+
838
+ # ------------------------------------------------------------------
839
+ # Actions
840
+ # ------------------------------------------------------------------
841
+
842
+ def _cancel_current(self) -> None:
843
+ job = self._current_job()
844
+ if job is not None:
845
+ self.cancel_job(job.job_id)
846
+
847
+ def _cancel_selected(self) -> None:
848
+ row = self._selected_row()
849
+ if row is not None:
850
+ self.cancel_job(row.job_id)
851
+
852
+ def cancel_job(self, job_id: str) -> None:
853
+ """F-50 and A.3: the owner cancels any job, whoever asked for it.
854
+
855
+ Off the GUI thread because :meth:`JobEngine.cancel` kills the worker
856
+ and then waits for N-22's five-second release; on the main thread
857
+ that would freeze the window for the whole of it.
858
+ """
859
+ if self._busy:
860
+ return
861
+ self._begin(tr("Cancelling…"))
862
+ signals = self._signals
863
+ engine = self.app.engine
864
+
865
+ def work() -> None:
866
+ error: EchoActError | None = None
867
+ try:
868
+ engine.cancel(job_id)
869
+ except EchoActError as exc:
870
+ error = exc
871
+ except Exception as exc: # noqa: BLE001
872
+ error = EchoActError(Code.INTERNAL, f"Cancelling failed ({type(exc).__name__}).")
873
+ signals.action_done.emit(error)
874
+
875
+ self._run_async(work)
876
+
877
+ def _toggle_rest(self) -> None:
878
+ """F-69's "stop integrations", and F-79's retry.
879
+
880
+ What the button does follows the listener, not ``rest_enabled``:
881
+ with the setting on and nothing listening -- F-79's bind failure --
882
+ this starts the service again and reports the code if the port is
883
+ still taken, which is the one action worth offering in that state.
884
+
885
+ Stopping is ``stop_service`` and nothing besides. F-52's three
886
+ steps are :meth:`ServiceRunner.stop`'s own, in the order the
887
+ requirement gives them, and the step that blocks new requests is the
888
+ ``drain`` it begins with -- ``rest_enabled`` is a startup setting
889
+ that no request path reads. Cancelling the integration's job from
890
+ here first would therefore run the cancellation *outside* the drain
891
+ and free the slot while the service was still admitting requests,
892
+ which is precisely the window F-52's ordering exists to close, and
893
+ would charge the owner two five-second cancellations for one click.
894
+
895
+ The setting is written before the stop because it is about the next
896
+ launch rather than this listener, and a settings write that is going
897
+ to fail should fail before anything has been torn down.
898
+ """
899
+ turning_off = self._service_running()
900
+ self._begin("")
901
+ app = self.app
902
+ signals = self._signals
903
+
904
+ def work() -> None:
905
+ error: EchoActError | None = None
906
+ try:
907
+ if turning_off:
908
+ app.update_settings(rest_enabled=False)
909
+ app.stop_service()
910
+ else:
911
+ app.update_settings(rest_enabled=True)
912
+ problem = app.start_service()
913
+ if problem is not None:
914
+ error = EchoActError(problem.code, problem.message)
915
+ except EchoActError as exc:
916
+ error = exc
917
+ except Exception as exc: # noqa: BLE001
918
+ error = EchoActError(
919
+ Code.INTERNAL, f"The service did not respond ({type(exc).__name__})."
920
+ )
921
+ signals.action_done.emit(error)
922
+
923
+ self._run_async(work)
924
+
925
+ def _toggle_mcp(self) -> None:
926
+ """MCP has no listener of ours to stop (F-52: the client starts it),
927
+ so turning it off is the setting plus the cancellation F-52 requires
928
+ of the jobs that integration already started."""
929
+ enable = not self.app.settings.mcp_enabled
930
+ self._begin("")
931
+ app = self.app
932
+ signals = self._signals
933
+
934
+ def work() -> None:
935
+ error: EchoActError | None = None
936
+ try:
937
+ app.update_settings(mcp_enabled=enable)
938
+ if not enable:
939
+ _cancel_path(app, RequestPath.MCP)
940
+ except EchoActError as exc:
941
+ error = exc
942
+ except Exception as exc: # noqa: BLE001
943
+ error = EchoActError(
944
+ Code.INTERNAL, f"The setting was not saved ({type(exc).__name__})."
945
+ )
946
+ signals.action_done.emit(error)
947
+
948
+ self._run_async(work)
949
+
950
+ def _begin(self, note: str) -> None:
951
+ self._busy = True
952
+ self.message.setVisible(bool(note))
953
+ self.message.setText(note)
954
+ self._update_enabled()
955
+
956
+ def _on_action_done(self, error: EchoActError | None) -> None:
957
+ self._busy = False
958
+ if error is None:
959
+ self.message.hide()
960
+ self.message.setText("")
961
+ else:
962
+ self.message.setText(f"{error.code.value} — {error.message}")
963
+ self.message.show()
964
+ self.refresh()
965
+ self.changed.emit()
966
+
967
+ # ------------------------------------------------------------------
968
+ # Diagnostics (F-72)
969
+ # ------------------------------------------------------------------
970
+
971
+ def export_diagnostics(self) -> None:
972
+ """Collect off the GUI thread, then show it for review.
973
+
974
+ F-72 exports only after the user has reviewed the contents, so this
975
+ opens a dialog and writes nothing; :func:`echoact.diagnostics.save`
976
+ runs from the dialog's own button.
977
+ """
978
+ if self._busy:
979
+ return
980
+ self._begin(tr("Preparing the export…"))
981
+ app = self.app
982
+ signals = self._signals
983
+
984
+ def work() -> None:
985
+ try:
986
+ text = diagnostics.export_text(app)
987
+ except EchoActError as exc:
988
+ signals.export_ready.emit(None, exc)
989
+ return
990
+ except Exception as exc: # noqa: BLE001
991
+ signals.export_ready.emit(
992
+ None,
993
+ EchoActError(
994
+ Code.INTERNAL,
995
+ f"The report could not be assembled ({type(exc).__name__}).",
996
+ ),
997
+ )
998
+ return
999
+ signals.export_ready.emit(text, None)
1000
+
1001
+ self._run_async(work)
1002
+
1003
+ def _on_export_ready(self, text: str | None, error: EchoActError | None) -> None:
1004
+ self._busy = False
1005
+ self.message.hide()
1006
+ self._update_enabled()
1007
+ if error is not None:
1008
+ self.message.setText(f"{error.code.value} — {error.message}")
1009
+ self.message.show()
1010
+ return
1011
+ if text is None:
1012
+ return
1013
+ self.last_export = text
1014
+ dialog = DiagnosticsDialog(text, self.palette_tokens, self)
1015
+ dialog.exec()
1016
+
1017
+ # ------------------------------------------------------------------
1018
+ # Qt lifecycle
1019
+ # ------------------------------------------------------------------
1020
+
1021
+ def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt override
1022
+ super().showEvent(event)
1023
+ self.refresh()
1024
+ self._timer.start()
1025
+
1026
+ def hideEvent(self, event: QHideEvent) -> None: # noqa: N802 - Qt override
1027
+ # Nothing is on screen to update, and F-22's requirement is about
1028
+ # what the user can see. The container keeps sampling regardless,
1029
+ # so the next show is current within one interval.
1030
+ self._timer.stop()
1031
+ super().hideEvent(event)
1032
+
1033
+
1034
+ class DiagnosticsDialog(QDialog):
1035
+ """F-72's review step: the whole export, before anything is written.
1036
+
1037
+ The text is shown in full rather than summarised. A summary would mean
1038
+ the user reviewed one document and saved a different one, which is not
1039
+ the review the requirement asks for.
1040
+ """
1041
+
1042
+ saved = Signal(str)
1043
+
1044
+ def __init__(
1045
+ self,
1046
+ text: str,
1047
+ palette: Palette,
1048
+ parent: QWidget | None = None,
1049
+ *,
1050
+ choose_path: Callable[[], str | None] | None = None,
1051
+ ) -> None:
1052
+ super().__init__(parent)
1053
+ self._text = text
1054
+ self._palette = palette
1055
+ self._choose_path = choose_path or self._ask_for_path
1056
+ m = METRICS
1057
+
1058
+ self.setWindowTitle(tr("Diagnostic export"))
1059
+ self.setModal(True)
1060
+ self.setMinimumSize(760, 560)
1061
+
1062
+ box = QVBoxLayout(self)
1063
+ box.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
1064
+ box.setSpacing(m.gap)
1065
+
1066
+ head = QHBoxLayout()
1067
+ head.setSpacing(m.gap)
1068
+ mark = QLabel()
1069
+ mark.setPixmap(icons.pixmap("info", palette.accent, 20, self.devicePixelRatioF()))
1070
+ mark.setAlignment(Qt.AlignmentFlag.AlignTop)
1071
+ head.addWidget(mark)
1072
+ intro = QLabel(
1073
+ tr("Review this before saving it. It is written to a file you choose and sent "
1074
+ "nowhere.")
1075
+ )
1076
+ intro.setWordWrap(True)
1077
+ head.addWidget(intro, 1)
1078
+ box.addLayout(head)
1079
+ excluded = QLabel(
1080
+ tr("It contains no document text, no audio, no credentials and no home directory "
1081
+ "path.")
1082
+ )
1083
+ excluded.setWordWrap(True)
1084
+ excluded.setProperty("role", "muted")
1085
+ box.addWidget(excluded)
1086
+
1087
+ self.view = QPlainTextEdit()
1088
+ self.view.setPlainText(text)
1089
+ # The report is column-aligned; a proportional face turns its
1090
+ # tables into ragged prose the reader has to decode twice.
1091
+ self.view.setFont(mono_font())
1092
+ self.view.setReadOnly(True)
1093
+ self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
1094
+ self.view.setAccessibleName(tr("Diagnostic export"))
1095
+ box.addWidget(self.view, 1)
1096
+
1097
+ self.status = QLabel("")
1098
+ self.status.setWordWrap(True)
1099
+ self.status.setProperty("role", "muted")
1100
+ box.addWidget(self.status)
1101
+
1102
+ buttons = QDialogButtonBox()
1103
+ self.save_button = buttons.addButton(
1104
+ tr("Save as…"), QDialogButtonBox.ButtonRole.ActionRole
1105
+ )
1106
+ self.save_button.setProperty("variant", "primary")
1107
+ self.close_button = buttons.addButton(tr("Close"), QDialogButtonBox.ButtonRole.RejectRole)
1108
+ buttons.rejected.connect(self.reject)
1109
+ self.save_button.clicked.connect(self.save)
1110
+ box.addWidget(buttons)
1111
+
1112
+ def text(self) -> str:
1113
+ return self._text
1114
+
1115
+ def _ask_for_path(self) -> str | None:
1116
+ """Offer a destination the user will find again.
1117
+
1118
+ The suggestion starts in the user's own directory rather than the
1119
+ app's data tree: F-72 exports so that the file can be sent to
1120
+ someone, and a file saved into ``<data>`` is one the sender has to
1121
+ be told how to find.
1122
+ """
1123
+ try:
1124
+ start = Path.home() / diagnostics.default_filename()
1125
+ except (OSError, RuntimeError):
1126
+ start = Path(diagnostics.default_filename())
1127
+ chosen, _filter = QFileDialog.getSaveFileName(
1128
+ self, tr("Save as…"), str(start), tr("Text files (*.txt)")
1129
+ )
1130
+ return chosen or None
1131
+
1132
+ def save(self) -> None:
1133
+ """Write what is on screen, to a location the user picks.
1134
+
1135
+ The destination is the user's, not the app's: F-72 exports for the
1136
+ user to send on, and writing into the data directory by default
1137
+ would hide the file in the one place they are least likely to look.
1138
+ """
1139
+ chosen = self._choose_path()
1140
+ if not chosen:
1141
+ return
1142
+ try:
1143
+ path = diagnostics.save(self._text, chosen)
1144
+ except EchoActError as exc:
1145
+ self.status.setProperty("role", "danger")
1146
+ self.status.setText(f"{exc.code.value} — {exc.message}")
1147
+ self.status.style().unpolish(self.status)
1148
+ self.status.style().polish(self.status)
1149
+ return
1150
+ self.status.setText(tr("Saved to {name}").format(name=path.name))
1151
+ self.saved.emit(str(path))
1152
+
1153
+
1154
+ # ======================================================================
1155
+ # Helpers
1156
+ # ======================================================================
1157
+
1158
+
1159
+ def _cancel_path(app: Application, path: RequestPath) -> None:
1160
+ """Cancel the running job if that integration owns it (F-52).
1161
+
1162
+ Only the running job: with one slot (F-47) there is at most one, and a
1163
+ job that has already finished is not something an integration is still
1164
+ doing.
1165
+ """
1166
+ job = app.engine.current()
1167
+ if job is not None and job.request_path is path and not job.state.is_terminal:
1168
+ app.engine.cancel(job.job_id)
1169
+
1170
+
1171
+ def _budget_text(budget: Budget | None) -> str:
1172
+ if budget is None:
1173
+ return "—"
1174
+ return (
1175
+ f"CPU {budget.cpu_percent}% · {memory_size(budget.memory_bytes)}"
1176
+ f" · {budget.intra_op_threads}+{budget.inter_op_threads} {tr('threads')}"
1177
+ )
1178
+
1179
+
1180
+ def _clock(value: float | None) -> str:
1181
+ """Wall-clock time of day. A date is noise on a screen showing the
1182
+ last few minutes of activity; the export carries the full stamp."""
1183
+ if not value:
1184
+ return "—"
1185
+ from datetime import datetime
1186
+
1187
+ try:
1188
+ return datetime.fromtimestamp(value).strftime("%H:%M:%S")
1189
+ except (OSError, OverflowError, ValueError):
1190
+ return "—"
1191
+
1192
+
1193
+ __all__ = ["DiagnosticsDialog", "StatusView"]