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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
echoact/diagnostics.py
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
"""F-72's diagnostic export: what to include, and what must never be in it.
|
|
2
|
+
|
|
3
|
+
The requirement names both halves. Included: the app, OS, and model
|
|
4
|
+
versions, the service state, the budget actually applied, recent error
|
|
5
|
+
codes, and logs stripped of sensitive information. Excluded, and this is
|
|
6
|
+
the point of the requirement: body text, audio, credentials, and the user's
|
|
7
|
+
home path. Nothing is transmitted anywhere -- there is no network client in
|
|
8
|
+
this module, and a test asserts the absence rather than trusting the reading.
|
|
9
|
+
|
|
10
|
+
Exclusion is arranged so that it holds by construction wherever it can:
|
|
11
|
+
|
|
12
|
+
* Body text is never *fetched*. ``Store.list_jobs`` leaves the snapshot out
|
|
13
|
+
unless a caller asks for it, and nothing here asks. A filter over text we
|
|
14
|
+
had already loaded would be the weaker design, because it would have to
|
|
15
|
+
recognise prose.
|
|
16
|
+
* Credentials never leave ``echoact.security`` in the first place: the store
|
|
17
|
+
exports a public projection with no key material, and the log tail is run
|
|
18
|
+
through the same :class:`~echoact.util.logging.SensitiveFilter` the logger
|
|
19
|
+
itself uses, so a credential formatted into a message by accident is
|
|
20
|
+
redacted here too.
|
|
21
|
+
* Paths are rendered through :func:`echoact.paths.redact`, and the log tail
|
|
22
|
+
gets a second pass that rewrites the data root and any user-profile
|
|
23
|
+
directory it finds inside a line of free text.
|
|
24
|
+
|
|
25
|
+
The report is returned as text so it can be shown to the user *before*
|
|
26
|
+
anything is written: F-72 makes review a precondition of export, not a
|
|
27
|
+
courtesy after it. :func:`save` writes exactly the string it is given,
|
|
28
|
+
which is the string that was on screen.
|
|
29
|
+
|
|
30
|
+
Everything here is English. ``echoact.ui.i18n`` is for widgets, and F-86 is
|
|
31
|
+
explicit that the display language is a presentation choice; a support file
|
|
32
|
+
that changes shape with it is harder to compare against another one.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import errno
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
import platform
|
|
41
|
+
import re
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from datetime import datetime
|
|
44
|
+
from importlib import metadata
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING, Any
|
|
47
|
+
|
|
48
|
+
from .domain import Budget
|
|
49
|
+
from .errors import Code, EchoActError, Problem
|
|
50
|
+
from .paths import data_dir, log_dir, redact
|
|
51
|
+
from .policy import LIST_PAGE_DEFAULT, REST_HOST
|
|
52
|
+
from .util import ids
|
|
53
|
+
from .util.logging import SensitiveFilter, get_logger
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
56
|
+
from .app import Application
|
|
57
|
+
|
|
58
|
+
log = get_logger("diagnostics")
|
|
59
|
+
|
|
60
|
+
#: How much log to carry. 4.1 caps retention at 7 days and 100 MB, which is
|
|
61
|
+
#: far more than a support file should hold; these two bound what is read
|
|
62
|
+
#: into memory as well as what is written out (N-21). They are here rather
|
|
63
|
+
#: than in ``echoact.policy`` only because nothing else needs them; if a
|
|
64
|
+
#: second caller appears they belong there.
|
|
65
|
+
LOG_TAIL_LINES = 200
|
|
66
|
+
LOG_LINE_MAX_CHARS = 300
|
|
67
|
+
|
|
68
|
+
#: A quoted run longer than this is treated as content and dropped. The one
|
|
69
|
+
#: long string in this system is the user's document (see
|
|
70
|
+
#: ``util.logging.job_context``), so a long ``%r`` in a log line is the shape
|
|
71
|
+
#: body text would take if it ever reached one.
|
|
72
|
+
QUOTED_TEXT_MAX_CHARS = 40
|
|
73
|
+
|
|
74
|
+
#: A run of non-ASCII letters longer than this is treated as content too.
|
|
75
|
+
#: Everything this application writes to a log is ASCII English -- the error
|
|
76
|
+
#: catalogue is English by N-24, and every helper in ``util.logging`` takes
|
|
77
|
+
#: identifiers and lengths -- so Hangul in a log line came from the user, and
|
|
78
|
+
#: a Korean sentence is well under ``QUOTED_TEXT_MAX_CHARS`` characters.
|
|
79
|
+
NON_ASCII_RUN_MAX_CHARS = 3
|
|
80
|
+
|
|
81
|
+
#: How many recent jobs get an N-25 entry. ``LIST_PAGE_DEFAULT`` is the
|
|
82
|
+
#: page size the rest of the app reads history in.
|
|
83
|
+
RECENT_JOB_LIMIT = LIST_PAGE_DEFAULT
|
|
84
|
+
|
|
85
|
+
_PACKAGES = (
|
|
86
|
+
"PySide6",
|
|
87
|
+
"onnxruntime",
|
|
88
|
+
"supertonic",
|
|
89
|
+
"numpy",
|
|
90
|
+
"soundfile",
|
|
91
|
+
"sounddevice",
|
|
92
|
+
"fastapi",
|
|
93
|
+
"uvicorn",
|
|
94
|
+
"fastmcp",
|
|
95
|
+
"psutil",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
_SENSITIVE = SensitiveFilter()
|
|
99
|
+
|
|
100
|
+
# A user-profile directory in the middle of a line of text, for the case the
|
|
101
|
+
# path was never a Path object and so never passed through ``redact``.
|
|
102
|
+
_HOME_LIKE = re.compile(
|
|
103
|
+
r"""(?ix)
|
|
104
|
+
(?: [a-z]:[\\/]+users[\\/]+[^\\/\s"'<>|]+ # C:\Users\name
|
|
105
|
+
| /(?:home|Users)/[^/\s"'<>|]+ # /home/name, /Users/name
|
|
106
|
+
)"""
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
_N = str(QUOTED_TEXT_MAX_CHARS)
|
|
110
|
+
_QUOTED = re.compile(
|
|
111
|
+
r"'(?:[^'\\]|\\.){" + _N + r",}'" + r'|"(?:[^"\\]|\\.){' + _N + r',}"',
|
|
112
|
+
re.DOTALL,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
_NON_ASCII_RUN = re.compile(
|
|
116
|
+
r"[^\x00-\x7f](?:[^\x00-\x7f]|[ ](?=[^\x00-\x7f])){" + str(NON_ASCII_RUN_MAX_CHARS) + r",}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
_CODE_TOKEN = re.compile(r"\b[A-Z][A-Z_]{3,}\b")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ======================================================================
|
|
123
|
+
# Scrubbing
|
|
124
|
+
# ======================================================================
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def scrub(line: str) -> str:
|
|
128
|
+
"""Make one line of log safe to hand to someone else.
|
|
129
|
+
|
|
130
|
+
Four passes, in an order that matters. The credential filter runs first
|
|
131
|
+
because a token can contain characters the path rules would chew on; the
|
|
132
|
+
data root is rewritten before the home directory because on Windows the
|
|
133
|
+
first lives inside the second and the more specific label is the more
|
|
134
|
+
useful one; the two content rules run last so they see whatever the path
|
|
135
|
+
rules left behind.
|
|
136
|
+
|
|
137
|
+
The content rules are heuristics and are described as such: a long
|
|
138
|
+
quoted run and a run of non-ASCII letters are the two shapes body text
|
|
139
|
+
takes when it reaches a log line. They are a second line of defence.
|
|
140
|
+
The first is N-20, which keeps body text out of a log in the first
|
|
141
|
+
place -- an English sentence logged unquoted would defeat both rules,
|
|
142
|
+
and nothing here could tell it from a message the app wrote itself.
|
|
143
|
+
"""
|
|
144
|
+
text = _strip_credentials(line)
|
|
145
|
+
text = _strip_paths(text)
|
|
146
|
+
text = _QUOTED.sub("'<text omitted>'", text)
|
|
147
|
+
text = _NON_ASCII_RUN.sub("<text omitted>", text)
|
|
148
|
+
text = text.rstrip()
|
|
149
|
+
if len(text) > LOG_LINE_MAX_CHARS:
|
|
150
|
+
text = text[:LOG_LINE_MAX_CHARS] + " …"
|
|
151
|
+
return text
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _strip_credentials(line: str) -> str:
|
|
155
|
+
"""Reuse the logger's own filter rather than a second copy of its rule.
|
|
156
|
+
|
|
157
|
+
Two regexes for one hazard would be one regex too many: the day the
|
|
158
|
+
logger learns a new credential shape, this must learn it at the same
|
|
159
|
+
instant or the export becomes the leak N-20 closed.
|
|
160
|
+
"""
|
|
161
|
+
record = logging.makeLogRecord({"msg": line, "args": ()})
|
|
162
|
+
_SENSITIVE.filter(record)
|
|
163
|
+
return str(record.msg)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _strip_paths(line: str) -> str:
|
|
167
|
+
text = line
|
|
168
|
+
for root, label in _path_labels():
|
|
169
|
+
text = _replace_path(text, root, label)
|
|
170
|
+
return _HOME_LIKE.sub("<home>", text)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _path_labels() -> tuple[tuple[str, str], ...]:
|
|
174
|
+
"""The roots worth naming, most specific first."""
|
|
175
|
+
roots: list[tuple[str, str]] = []
|
|
176
|
+
for getter, label in ((data_dir, "<data>"), (Path.home, "<home>")):
|
|
177
|
+
try:
|
|
178
|
+
roots.append((str(getter()), label))
|
|
179
|
+
except (OSError, RuntimeError): # a home directory need not exist
|
|
180
|
+
continue
|
|
181
|
+
return tuple(roots)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _replace_path(text: str, root: str, label: str) -> str:
|
|
185
|
+
"""Replace ``root`` however it was spelled: either separator, either case.
|
|
186
|
+
|
|
187
|
+
Windows paths reach a log in both slash directions -- ``pathlib`` writes
|
|
188
|
+
backslashes, a URL or a POSIX-flavoured library writes forward ones --
|
|
189
|
+
and comparing only one form would leave the other in the file.
|
|
190
|
+
"""
|
|
191
|
+
if not root:
|
|
192
|
+
return text
|
|
193
|
+
variants = {root, root.replace("\\", "/"), root.replace("/", "\\")}
|
|
194
|
+
for variant in sorted(variants, key=len, reverse=True):
|
|
195
|
+
pattern = re.compile(re.escape(variant), re.IGNORECASE if os.name == "nt" else 0)
|
|
196
|
+
text = pattern.sub(label, text)
|
|
197
|
+
return text
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ======================================================================
|
|
201
|
+
# The report
|
|
202
|
+
# ======================================================================
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class JobTrace:
|
|
207
|
+
"""N-25's traceable entry, and deliberately only that.
|
|
208
|
+
|
|
209
|
+
Job id, timestamp, stage, error code, applied budget: the five the
|
|
210
|
+
requirement names. Not the text, not the result, not the voice -- a
|
|
211
|
+
support file is read by someone who is not the user, and everything
|
|
212
|
+
beyond these five would be extra exposure for no diagnostic gain.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
job_id: str
|
|
216
|
+
timestamp: float
|
|
217
|
+
stage: str
|
|
218
|
+
error_code: str | None
|
|
219
|
+
budget: Budget | None
|
|
220
|
+
|
|
221
|
+
def line(self) -> str:
|
|
222
|
+
return (
|
|
223
|
+
f"{self.job_id} {_stamp(self.timestamp)} {self.stage:<16}"
|
|
224
|
+
f" {self.error_code or '-':<26} {_budget_text(self.budget)}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True, slots=True)
|
|
229
|
+
class ModelLine:
|
|
230
|
+
"""F-72's "model versions": the pinned revision, not just a name."""
|
|
231
|
+
|
|
232
|
+
model_id: str
|
|
233
|
+
display_name: str
|
|
234
|
+
revision: str
|
|
235
|
+
state: str
|
|
236
|
+
bytes_present: int
|
|
237
|
+
bytes_total: int
|
|
238
|
+
runnable: bool
|
|
239
|
+
unavailable_reason: str | None
|
|
240
|
+
#: True when the files came from the ``supertonic`` package's own cache
|
|
241
|
+
#: rather than this app's. Worth reporting: F-65's delete does not
|
|
242
|
+
#: touch that directory, so "ready" means something different there.
|
|
243
|
+
using_package_cache: bool = False
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True, slots=True)
|
|
247
|
+
class ClientLine:
|
|
248
|
+
"""One integration client, from the projection that has no key material.
|
|
249
|
+
|
|
250
|
+
The ``ref`` is left out although the public projection carries it: it is
|
|
251
|
+
the readable half of a token's own text, and a support file has no use
|
|
252
|
+
for it that the client id does not already serve.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
client_id: str
|
|
256
|
+
name: str
|
|
257
|
+
status: str
|
|
258
|
+
capabilities: tuple[str, ...]
|
|
259
|
+
expires_at: float | None
|
|
260
|
+
last_access_at: float | None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(frozen=True, slots=True)
|
|
264
|
+
class ServiceState:
|
|
265
|
+
"""F-72's "service state", including the F-79 case where it is off
|
|
266
|
+
because the port would not bind rather than because the owner said so."""
|
|
267
|
+
|
|
268
|
+
rest_enabled: bool
|
|
269
|
+
rest_running: bool
|
|
270
|
+
host: str
|
|
271
|
+
port: int
|
|
272
|
+
mcp_enabled: bool
|
|
273
|
+
problems: tuple[str, ...]
|
|
274
|
+
clients: tuple[ClientLine, ...]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True, slots=True)
|
|
278
|
+
class BudgetState:
|
|
279
|
+
"""The budget "actually applied", which is three different numbers.
|
|
280
|
+
|
|
281
|
+
F-78 separates the configured value from the one a job is running under,
|
|
282
|
+
and N-03 separates both from what the platform will actually enforce.
|
|
283
|
+
Reporting one number would misstate whichever of the three the reader
|
|
284
|
+
happened to need.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
configured: Budget | None
|
|
288
|
+
configured_error: str | None
|
|
289
|
+
in_force: Budget | None
|
|
290
|
+
running_job: Budget | None
|
|
291
|
+
facility: str
|
|
292
|
+
memory_enforcement: str
|
|
293
|
+
cpu_enforcement: str
|
|
294
|
+
memory_basis: str
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@dataclass(frozen=True, slots=True)
|
|
298
|
+
class UsageLine:
|
|
299
|
+
"""F-22's figures for the generation job alone (N-21), with their source.
|
|
300
|
+
|
|
301
|
+
``source`` and ``peak_commit_bytes`` are carried because N-03 permits
|
|
302
|
+
the figure on screen and the figure limits are tested against to differ;
|
|
303
|
+
a report that printed one number would be claiming they do not.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
rss_bytes: int
|
|
307
|
+
cpu_percent: float
|
|
308
|
+
peak_rss_bytes: int
|
|
309
|
+
peak_commit_bytes: int
|
|
310
|
+
source: str
|
|
311
|
+
age_s: float
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@dataclass(frozen=True, slots=True)
|
|
315
|
+
class DiagnosticReport:
|
|
316
|
+
"""Everything F-72 asks for, as data first and text second.
|
|
317
|
+
|
|
318
|
+
Data first because the exclusions are then testable field by field, and
|
|
319
|
+
because the GUI shows the same object it would save.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
generated_at: float
|
|
323
|
+
app_version: str
|
|
324
|
+
python_version: str
|
|
325
|
+
os_description: str
|
|
326
|
+
packages: tuple[tuple[str, str], ...]
|
|
327
|
+
models: tuple[ModelLine, ...]
|
|
328
|
+
service: ServiceState
|
|
329
|
+
budget: BudgetState
|
|
330
|
+
usage: UsageLine | None
|
|
331
|
+
current_job: JobTrace | None
|
|
332
|
+
current_job_path: str | None
|
|
333
|
+
current_job_client: str | None
|
|
334
|
+
jobs: tuple[JobTrace, ...]
|
|
335
|
+
error_codes: tuple[tuple[str, int], ...]
|
|
336
|
+
log_lines: tuple[str, ...]
|
|
337
|
+
notes: tuple[str, ...]
|
|
338
|
+
|
|
339
|
+
def to_text(self) -> str:
|
|
340
|
+
return render(self)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ======================================================================
|
|
344
|
+
# Collection
|
|
345
|
+
# ======================================================================
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def collect(
|
|
349
|
+
app: Application,
|
|
350
|
+
*,
|
|
351
|
+
now: float | None = None,
|
|
352
|
+
job_limit: int = RECENT_JOB_LIMIT,
|
|
353
|
+
log_lines: int = LOG_TAIL_LINES,
|
|
354
|
+
) -> DiagnosticReport:
|
|
355
|
+
"""Gather the report. Never raises for a section it cannot read.
|
|
356
|
+
|
|
357
|
+
A diagnostic that fails when something is broken is worth nothing: the
|
|
358
|
+
moment a section is unreadable is the moment its absence is itself
|
|
359
|
+
evidence. Each section is therefore guarded, and what went wrong is
|
|
360
|
+
recorded in ``notes`` rather than propagated.
|
|
361
|
+
"""
|
|
362
|
+
at = ids.now() if now is None else now
|
|
363
|
+
notes: list[str] = []
|
|
364
|
+
|
|
365
|
+
models = _collect_models(app, notes)
|
|
366
|
+
service = _collect_service(app, at, notes)
|
|
367
|
+
budget = collect_budget(app, notes)
|
|
368
|
+
usage = _collect_usage(app, notes)
|
|
369
|
+
current, current_path, current_client = _collect_current(app, notes)
|
|
370
|
+
jobs = _collect_jobs(app, job_limit, notes)
|
|
371
|
+
lines = _collect_log(log_lines, notes)
|
|
372
|
+
problems = _startup_problem_codes(app)
|
|
373
|
+
codes = _tally_codes(jobs, current, problems, lines)
|
|
374
|
+
|
|
375
|
+
return DiagnosticReport(
|
|
376
|
+
generated_at=at,
|
|
377
|
+
app_version=_app_version(),
|
|
378
|
+
python_version=f"{platform.python_version()} ({platform.python_implementation()})",
|
|
379
|
+
os_description=_os_description(),
|
|
380
|
+
packages=_package_versions(),
|
|
381
|
+
models=models,
|
|
382
|
+
service=service,
|
|
383
|
+
budget=budget,
|
|
384
|
+
usage=usage,
|
|
385
|
+
current_job=current,
|
|
386
|
+
current_job_path=current_path,
|
|
387
|
+
current_job_client=current_client,
|
|
388
|
+
jobs=jobs,
|
|
389
|
+
error_codes=codes,
|
|
390
|
+
log_lines=lines,
|
|
391
|
+
notes=tuple(notes),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _app_version() -> str:
|
|
396
|
+
from . import __version__
|
|
397
|
+
|
|
398
|
+
return __version__
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _os_description() -> str:
|
|
402
|
+
"""System, release, build, and architecture -- and no host name.
|
|
403
|
+
|
|
404
|
+
``platform.uname()`` would be the shorter call and would carry the
|
|
405
|
+
machine's name, which on a personal computer is frequently the user's
|
|
406
|
+
own. F-72 excludes the home path for that reason and the host name
|
|
407
|
+
fails the same test.
|
|
408
|
+
"""
|
|
409
|
+
return f"{platform.system()} {platform.release()} ({platform.version()}) {platform.machine()}"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _package_versions() -> tuple[tuple[str, str], ...]:
|
|
413
|
+
out: list[tuple[str, str]] = []
|
|
414
|
+
for name in _PACKAGES:
|
|
415
|
+
try:
|
|
416
|
+
out.append((name, metadata.version(name)))
|
|
417
|
+
except metadata.PackageNotFoundError:
|
|
418
|
+
continue
|
|
419
|
+
return tuple(out)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _collect_models(app: Application, notes: list[str]) -> tuple[ModelLine, ...]:
|
|
423
|
+
try:
|
|
424
|
+
statuses = app.registry.statuses(_configured_budget(app)[0])
|
|
425
|
+
except Exception as exc: # noqa: BLE001 - a report must survive a broken section
|
|
426
|
+
notes.append(f"models: unavailable ({type(exc).__name__})")
|
|
427
|
+
return ()
|
|
428
|
+
out: list[ModelLine] = []
|
|
429
|
+
for status in statuses:
|
|
430
|
+
try:
|
|
431
|
+
revision = app.registry.entry(status.model_id).revision
|
|
432
|
+
except Exception: # noqa: BLE001
|
|
433
|
+
revision = "unknown"
|
|
434
|
+
out.append(
|
|
435
|
+
ModelLine(
|
|
436
|
+
model_id=status.model_id,
|
|
437
|
+
display_name=status.display_name,
|
|
438
|
+
revision=revision,
|
|
439
|
+
state=str(status.state),
|
|
440
|
+
bytes_present=status.bytes_present,
|
|
441
|
+
bytes_total=status.bytes_total,
|
|
442
|
+
runnable=status.runnable,
|
|
443
|
+
unavailable_reason=status.unavailable_reason,
|
|
444
|
+
using_package_cache=bool(getattr(status, "using_package_cache", False)),
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
return tuple(out)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _collect_service(app: Application, at: float, notes: list[str]) -> ServiceState:
|
|
451
|
+
settings = app.settings
|
|
452
|
+
clients: tuple[ClientLine, ...] = ()
|
|
453
|
+
try:
|
|
454
|
+
clients = tuple(
|
|
455
|
+
ClientLine(
|
|
456
|
+
client_id=str(row["client_id"]),
|
|
457
|
+
name=str(row["name"]),
|
|
458
|
+
status=str(row["status"]),
|
|
459
|
+
capabilities=tuple(str(c) for c in row["effective_capabilities"]),
|
|
460
|
+
expires_at=_as_time(row.get("expires_at")),
|
|
461
|
+
last_access_at=_as_time(row.get("last_access_at")),
|
|
462
|
+
)
|
|
463
|
+
for row in app.credentials.export_public(now=at)
|
|
464
|
+
)
|
|
465
|
+
except Exception as exc: # noqa: BLE001
|
|
466
|
+
notes.append(f"clients: unavailable ({type(exc).__name__})")
|
|
467
|
+
running = False
|
|
468
|
+
try:
|
|
469
|
+
running = bool(app.service_running)
|
|
470
|
+
except Exception as exc: # noqa: BLE001
|
|
471
|
+
notes.append(f"service state: unavailable ({type(exc).__name__})")
|
|
472
|
+
return ServiceState(
|
|
473
|
+
rest_enabled=bool(settings.rest_enabled),
|
|
474
|
+
rest_running=running,
|
|
475
|
+
host=REST_HOST,
|
|
476
|
+
port=int(settings.rest_port),
|
|
477
|
+
mcp_enabled=bool(settings.mcp_enabled),
|
|
478
|
+
problems=_startup_problem_codes(app),
|
|
479
|
+
clients=clients,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _configured_budget(app: Application) -> tuple[Budget | None, str | None]:
|
|
484
|
+
from .config.budget import resolve_budget_from_system
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
return resolve_budget_from_system(app.settings), None
|
|
488
|
+
except EchoActError as exc:
|
|
489
|
+
return None, f"{exc.code.value}: {exc.message}"
|
|
490
|
+
except Exception as exc: # noqa: BLE001
|
|
491
|
+
return None, type(exc).__name__
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def collect_budget(app: Application, notes: list[str] | None = None) -> BudgetState:
|
|
495
|
+
"""The three budgets and the enforcement behind them.
|
|
496
|
+
|
|
497
|
+
Public because F-69's screen shows exactly this and has no business
|
|
498
|
+
assembling it a second way; a second assembly is how the screen and the
|
|
499
|
+
support file come to disagree about what was applied.
|
|
500
|
+
"""
|
|
501
|
+
if notes is None:
|
|
502
|
+
notes = []
|
|
503
|
+
configured, error = _configured_budget(app)
|
|
504
|
+
in_force: Budget | None = None
|
|
505
|
+
running: Budget | None = None
|
|
506
|
+
facility = "none"
|
|
507
|
+
memory = "unavailable"
|
|
508
|
+
cpu = "unavailable"
|
|
509
|
+
basis = "none"
|
|
510
|
+
try:
|
|
511
|
+
in_force = app.supervisor.budget
|
|
512
|
+
limits = app.supervisor.limits
|
|
513
|
+
if limits is not None:
|
|
514
|
+
facility = limits.facility or "none"
|
|
515
|
+
memory = str(limits.memory)
|
|
516
|
+
cpu = str(limits.cpu)
|
|
517
|
+
basis = str(limits.memory_basis)
|
|
518
|
+
except Exception as exc: # noqa: BLE001
|
|
519
|
+
notes.append(f"worker limits: unavailable ({type(exc).__name__})")
|
|
520
|
+
try:
|
|
521
|
+
job = app.engine.current()
|
|
522
|
+
running = job.budget if job is not None else None
|
|
523
|
+
except Exception as exc: # noqa: BLE001
|
|
524
|
+
notes.append(f"current job budget: unavailable ({type(exc).__name__})")
|
|
525
|
+
return BudgetState(
|
|
526
|
+
configured=configured,
|
|
527
|
+
configured_error=error,
|
|
528
|
+
in_force=in_force,
|
|
529
|
+
running_job=running,
|
|
530
|
+
facility=facility,
|
|
531
|
+
memory_enforcement=memory,
|
|
532
|
+
cpu_enforcement=cpu,
|
|
533
|
+
memory_basis=basis,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _collect_usage(app: Application, notes: list[str]) -> UsageLine | None:
|
|
538
|
+
try:
|
|
539
|
+
usage = app.supervisor.usage()
|
|
540
|
+
except Exception as exc: # noqa: BLE001
|
|
541
|
+
notes.append(f"usage: unavailable ({type(exc).__name__})")
|
|
542
|
+
return None
|
|
543
|
+
if usage is None:
|
|
544
|
+
return None
|
|
545
|
+
return UsageLine(
|
|
546
|
+
rss_bytes=usage.rss_bytes,
|
|
547
|
+
cpu_percent=usage.cpu_percent,
|
|
548
|
+
peak_rss_bytes=usage.peak_rss_bytes,
|
|
549
|
+
peak_commit_bytes=usage.peak_commit_bytes,
|
|
550
|
+
source=usage.source,
|
|
551
|
+
age_s=usage.age_s,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _collect_current(
|
|
556
|
+
app: Application, notes: list[str]
|
|
557
|
+
) -> tuple[JobTrace | None, str | None, str | None]:
|
|
558
|
+
try:
|
|
559
|
+
job = app.engine.current()
|
|
560
|
+
except Exception as exc: # noqa: BLE001
|
|
561
|
+
notes.append(f"current job: unavailable ({type(exc).__name__})")
|
|
562
|
+
return None, None, None
|
|
563
|
+
if job is None:
|
|
564
|
+
return None, None, None
|
|
565
|
+
trace = JobTrace(
|
|
566
|
+
job_id=job.job_id,
|
|
567
|
+
timestamp=job.started_at or job.created_at,
|
|
568
|
+
stage=str(job.state),
|
|
569
|
+
error_code=job.error_code,
|
|
570
|
+
budget=job.budget,
|
|
571
|
+
)
|
|
572
|
+
return trace, str(job.request_path), job.client_label
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _collect_jobs(app: Application, limit: int, notes: list[str]) -> tuple[JobTrace, ...]:
|
|
576
|
+
"""Recent jobs, without their snapshots.
|
|
577
|
+
|
|
578
|
+
``include_source_text`` is left at its default on purpose: F-56 makes
|
|
579
|
+
the summary the default projection and the snapshot a separate,
|
|
580
|
+
separately authorised request, so the export simply never has the text
|
|
581
|
+
to leak.
|
|
582
|
+
"""
|
|
583
|
+
try:
|
|
584
|
+
page = app.store.list_jobs(limit=limit)
|
|
585
|
+
except EchoActError as exc:
|
|
586
|
+
notes.append(f"job history: {exc.code.value}")
|
|
587
|
+
return ()
|
|
588
|
+
except Exception as exc: # noqa: BLE001
|
|
589
|
+
notes.append(f"job history: unavailable ({type(exc).__name__})")
|
|
590
|
+
return ()
|
|
591
|
+
return tuple(
|
|
592
|
+
JobTrace(
|
|
593
|
+
job_id=row.job_id,
|
|
594
|
+
timestamp=row.ended_at or row.started_at or row.created_at,
|
|
595
|
+
stage=str(row.state),
|
|
596
|
+
error_code=row.error_code,
|
|
597
|
+
budget=row.budget,
|
|
598
|
+
)
|
|
599
|
+
for row in page.items
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _collect_log(max_lines: int, notes: list[str]) -> tuple[str, ...]:
|
|
604
|
+
try:
|
|
605
|
+
path = log_dir() / "echoact.log"
|
|
606
|
+
if not path.is_file():
|
|
607
|
+
notes.append("log: no log file")
|
|
608
|
+
return ()
|
|
609
|
+
text = _tail(path, max_lines)
|
|
610
|
+
except OSError as exc:
|
|
611
|
+
notes.append(f"log: unreadable ({exc.__class__.__name__})")
|
|
612
|
+
return ()
|
|
613
|
+
lines = [scrub(line) for line in text.splitlines() if line.strip()]
|
|
614
|
+
return tuple(lines[-max_lines:])
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _tail(path: Path, max_lines: int) -> str:
|
|
618
|
+
"""Read the end of a file without loading the whole of it.
|
|
619
|
+
|
|
620
|
+
4.1 lets a log reach 100 MB and N-21 forbids a query that loads
|
|
621
|
+
unboundedly, so the read is bounded by bytes as well as by lines and a
|
|
622
|
+
partial first line is discarded rather than shown truncated.
|
|
623
|
+
"""
|
|
624
|
+
budget = max_lines * (LOG_LINE_MAX_CHARS + 60)
|
|
625
|
+
with path.open("rb") as fh:
|
|
626
|
+
fh.seek(0, os.SEEK_END)
|
|
627
|
+
size = fh.tell()
|
|
628
|
+
fh.seek(max(0, size - budget))
|
|
629
|
+
raw = fh.read(budget)
|
|
630
|
+
text = raw.decode("utf-8", errors="replace")
|
|
631
|
+
if size > budget and "\n" in text:
|
|
632
|
+
text = text.split("\n", 1)[1]
|
|
633
|
+
return text
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _startup_problem_codes(app: Application) -> tuple[str, ...]:
|
|
637
|
+
try:
|
|
638
|
+
problems: list[Problem] = list(app.startup.problems)
|
|
639
|
+
except Exception: # noqa: BLE001
|
|
640
|
+
return ()
|
|
641
|
+
return tuple(str(p.code) for p in problems)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _tally_codes(
|
|
645
|
+
jobs: tuple[JobTrace, ...],
|
|
646
|
+
current: JobTrace | None,
|
|
647
|
+
problems: tuple[str, ...],
|
|
648
|
+
log_lines: tuple[str, ...],
|
|
649
|
+
) -> tuple[tuple[str, int], ...]:
|
|
650
|
+
"""F-72's "recent error codes", from every place one is recorded.
|
|
651
|
+
|
|
652
|
+
The log is included because an error that never reached a job row -- a
|
|
653
|
+
failed bind, a damaged settings file -- is exactly the kind the reader
|
|
654
|
+
of a support file is looking for. Only tokens that are real
|
|
655
|
+
:class:`~echoact.errors.Code` members count, so an upper-case word in a
|
|
656
|
+
message cannot invent a code.
|
|
657
|
+
"""
|
|
658
|
+
known = {c.value for c in Code}
|
|
659
|
+
counts: dict[str, int] = {}
|
|
660
|
+
for trace in (*jobs, *(t for t in (current,) if t is not None)):
|
|
661
|
+
if trace.error_code:
|
|
662
|
+
counts[trace.error_code] = counts.get(trace.error_code, 0) + 1
|
|
663
|
+
for code in problems:
|
|
664
|
+
counts[code] = counts.get(code, 0) + 1
|
|
665
|
+
for line in log_lines:
|
|
666
|
+
for token in _CODE_TOKEN.findall(line):
|
|
667
|
+
if token in known:
|
|
668
|
+
counts[token] = counts.get(token, 0) + 1
|
|
669
|
+
return tuple(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _as_time(value: Any) -> float | None:
|
|
673
|
+
try:
|
|
674
|
+
return None if value is None else float(value)
|
|
675
|
+
except (TypeError, ValueError):
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# ======================================================================
|
|
680
|
+
# Rendering
|
|
681
|
+
# ======================================================================
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _stamp(value: float | None) -> str:
|
|
685
|
+
if value is None:
|
|
686
|
+
return "-"
|
|
687
|
+
try:
|
|
688
|
+
return datetime.fromtimestamp(value).astimezone().isoformat(timespec="seconds")
|
|
689
|
+
except (OSError, OverflowError, ValueError):
|
|
690
|
+
return f"{value:.0f}"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _gib(n: int) -> str:
|
|
694
|
+
gib = n / (1 << 30)
|
|
695
|
+
return f"{gib:.2f} GiB" if gib >= 1 else f"{n / (1 << 20):.0f} MiB"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _mb(n: int) -> str:
|
|
699
|
+
return f"{n / 1_000_000:.1f} MB"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _budget_text(budget: Budget | None) -> str:
|
|
703
|
+
if budget is None:
|
|
704
|
+
return "-"
|
|
705
|
+
return (
|
|
706
|
+
f"CPU {budget.cpu_percent}% / {_gib(budget.memory_bytes)} / "
|
|
707
|
+
f"{budget.intra_op_threads}+{budget.inter_op_threads} threads"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _rows(pairs: list[tuple[str, str]], indent: str = " ") -> list[str]:
|
|
712
|
+
if not pairs:
|
|
713
|
+
return []
|
|
714
|
+
width = max(len(name) for name, _ in pairs)
|
|
715
|
+
return [f"{indent}{name.ljust(width)} {value}" for name, value in pairs]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def render(report: DiagnosticReport) -> str:
|
|
719
|
+
"""The text the user reviews and, unchanged, the text that gets saved.
|
|
720
|
+
|
|
721
|
+
One rendering rather than a summary on screen and a fuller file: F-72
|
|
722
|
+
makes review the precondition for export, and a review of something
|
|
723
|
+
other than what is exported is not a review.
|
|
724
|
+
"""
|
|
725
|
+
out: list[str] = [
|
|
726
|
+
"EchoAct diagnostic report",
|
|
727
|
+
f"Generated {_stamp(report.generated_at)}",
|
|
728
|
+
"",
|
|
729
|
+
"This file is written locally and sent nowhere. It contains no document text,",
|
|
730
|
+
"no audio, no credentials, and no home directory path.",
|
|
731
|
+
"",
|
|
732
|
+
"APPLICATION",
|
|
733
|
+
]
|
|
734
|
+
out += _rows(
|
|
735
|
+
[
|
|
736
|
+
("EchoAct", report.app_version),
|
|
737
|
+
("Python", report.python_version),
|
|
738
|
+
("Operating system", report.os_description),
|
|
739
|
+
("Data directory", "<data> (path withheld: F-72)"),
|
|
740
|
+
]
|
|
741
|
+
)
|
|
742
|
+
if report.packages:
|
|
743
|
+
out += _rows([(name, version) for name, version in report.packages], indent=" ")
|
|
744
|
+
|
|
745
|
+
out += ["", "MODELS"]
|
|
746
|
+
if not report.models:
|
|
747
|
+
out.append(" (none reported)")
|
|
748
|
+
for m in report.models:
|
|
749
|
+
state = m.state if m.runnable else f"{m.state}, cannot run"
|
|
750
|
+
if m.using_package_cache:
|
|
751
|
+
state += ", from the package cache"
|
|
752
|
+
out.append(
|
|
753
|
+
f" {m.model_id} rev {m.revision[:12]} {state} "
|
|
754
|
+
f"{_mb(m.bytes_present)} of {_mb(m.bytes_total)}"
|
|
755
|
+
)
|
|
756
|
+
if m.unavailable_reason:
|
|
757
|
+
out.append(f" {m.unavailable_reason}")
|
|
758
|
+
|
|
759
|
+
svc = report.service
|
|
760
|
+
out += ["", "SERVICE"]
|
|
761
|
+
rest = "on" if svc.rest_enabled else "off"
|
|
762
|
+
running = "listening" if svc.rest_running else "not listening"
|
|
763
|
+
out += _rows(
|
|
764
|
+
[
|
|
765
|
+
("REST", f"{rest}, {running}, {svc.host}:{svc.port}"),
|
|
766
|
+
("MCP", "enabled" if svc.mcp_enabled else "disabled"),
|
|
767
|
+
]
|
|
768
|
+
)
|
|
769
|
+
if svc.problems:
|
|
770
|
+
out.append(f" Start-up problems {', '.join(svc.problems)}")
|
|
771
|
+
if svc.clients:
|
|
772
|
+
out.append(" Clients")
|
|
773
|
+
for c in svc.clients:
|
|
774
|
+
out.append(
|
|
775
|
+
f" {c.client_id} {c.name} {c.status} "
|
|
776
|
+
f"[{', '.join(c.capabilities)}] expires {_stamp(c.expires_at)} "
|
|
777
|
+
f"last access {_stamp(c.last_access_at)}"
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
b = report.budget
|
|
781
|
+
out += ["", "BUDGET APPLIED"]
|
|
782
|
+
pairs = [
|
|
783
|
+
("Configured", b.configured_error or _budget_text(b.configured)),
|
|
784
|
+
("Worker in force", _budget_text(b.in_force)),
|
|
785
|
+
("Running job", _budget_text(b.running_job)),
|
|
786
|
+
(
|
|
787
|
+
"Enforcement",
|
|
788
|
+
f"memory {b.memory_enforcement} ({b.memory_basis} basis), "
|
|
789
|
+
f"CPU {b.cpu_enforcement} — {b.facility}",
|
|
790
|
+
),
|
|
791
|
+
]
|
|
792
|
+
if report.usage is not None:
|
|
793
|
+
u = report.usage
|
|
794
|
+
pairs += [
|
|
795
|
+
(
|
|
796
|
+
"Generation job usage",
|
|
797
|
+
f"CPU {u.cpu_percent:.0f}%, RSS {_gib(u.rss_bytes)}, "
|
|
798
|
+
f"peak RSS {_gib(u.peak_rss_bytes)}, "
|
|
799
|
+
f"peak commit {_gib(u.peak_commit_bytes)}",
|
|
800
|
+
),
|
|
801
|
+
("Measured by", f"{u.source}, {u.age_s:.1f} s ago"),
|
|
802
|
+
]
|
|
803
|
+
out += _rows(pairs)
|
|
804
|
+
|
|
805
|
+
out += ["", "CURRENT JOB"]
|
|
806
|
+
if report.current_job is None:
|
|
807
|
+
out.append(" (idle)")
|
|
808
|
+
else:
|
|
809
|
+
who = report.current_job_client or "-"
|
|
810
|
+
out.append(f" requested by {report.current_job_path or '-'} ({who})")
|
|
811
|
+
out.append(" " + report.current_job.line())
|
|
812
|
+
|
|
813
|
+
out += ["", "RECENT JOBS (id · timestamp · stage · error code · applied budget)"]
|
|
814
|
+
if not report.jobs:
|
|
815
|
+
out.append(" (none)")
|
|
816
|
+
for trace in report.jobs:
|
|
817
|
+
out.append(" " + trace.line())
|
|
818
|
+
|
|
819
|
+
out += ["", "RECENT ERROR CODES"]
|
|
820
|
+
if not report.error_codes:
|
|
821
|
+
out.append(" (none)")
|
|
822
|
+
out += _rows([(code, str(n)) for code, n in report.error_codes])
|
|
823
|
+
|
|
824
|
+
if report.notes:
|
|
825
|
+
out += ["", "NOTES"]
|
|
826
|
+
out += [f" {note}" for note in report.notes]
|
|
827
|
+
|
|
828
|
+
n = len(report.log_lines)
|
|
829
|
+
out += ["", f"LOG (last {n} {'line' if n == 1 else 'lines'}, stripped)"]
|
|
830
|
+
if not report.log_lines:
|
|
831
|
+
out.append(" (no log lines)")
|
|
832
|
+
out += [f" {line}" for line in report.log_lines]
|
|
833
|
+
out.append("")
|
|
834
|
+
return "\n".join(out)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# ======================================================================
|
|
838
|
+
# Saving
|
|
839
|
+
# ======================================================================
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def default_filename(now: float | None = None) -> str:
|
|
843
|
+
at = ids.now() if now is None else now
|
|
844
|
+
stamp = datetime.fromtimestamp(at).strftime("%Y%m%d-%H%M%S")
|
|
845
|
+
return f"echoact-diagnostics-{stamp}.txt"
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def save(text: str, destination: str | Path) -> Path:
|
|
849
|
+
"""Write the reviewed text to a file the user chose.
|
|
850
|
+
|
|
851
|
+
Takes the text rather than the report: F-72 exports what the user
|
|
852
|
+
reviewed, so re-rendering here -- with a clock that has moved and a
|
|
853
|
+
usage sample that has changed -- would write a document nobody saw.
|
|
854
|
+
|
|
855
|
+
Every failure leaves as an :class:`EchoActError` with the closest code
|
|
856
|
+
the catalogue has, because rule 3 applies to a helper as much as to a
|
|
857
|
+
subsystem and the caller here is a dialog with one message area.
|
|
858
|
+
"""
|
|
859
|
+
path = Path(destination)
|
|
860
|
+
try:
|
|
861
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
862
|
+
path.write_text(text, encoding="utf-8")
|
|
863
|
+
except PermissionError as exc:
|
|
864
|
+
raise EchoActError(
|
|
865
|
+
Code.FILE_PERMISSION,
|
|
866
|
+
"The diagnostic file could not be written to that location.",
|
|
867
|
+
detail={"path": redact(path)},
|
|
868
|
+
cause=exc,
|
|
869
|
+
) from exc
|
|
870
|
+
except OSError as exc:
|
|
871
|
+
code = Code.STORAGE_FULL if exc.errno == errno.ENOSPC else Code.INTERNAL
|
|
872
|
+
raise EchoActError(
|
|
873
|
+
code,
|
|
874
|
+
"The diagnostic file could not be written.",
|
|
875
|
+
detail={"path": redact(path)},
|
|
876
|
+
cause=exc,
|
|
877
|
+
) from exc
|
|
878
|
+
log.info("diagnostic export written to %s (%d bytes)", redact(path), len(text))
|
|
879
|
+
return path
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def export_text(app: Application, *, now: float | None = None) -> str:
|
|
883
|
+
"""Collect and render in one call, for a caller that only wants the text."""
|
|
884
|
+
return render(collect(app, now=now))
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
__all__ = [
|
|
888
|
+
"BudgetState",
|
|
889
|
+
"ClientLine",
|
|
890
|
+
"DiagnosticReport",
|
|
891
|
+
"JobTrace",
|
|
892
|
+
"ModelLine",
|
|
893
|
+
"ServiceState",
|
|
894
|
+
"UsageLine",
|
|
895
|
+
"collect",
|
|
896
|
+
"collect_budget",
|
|
897
|
+
"default_filename",
|
|
898
|
+
"export_text",
|
|
899
|
+
"render",
|
|
900
|
+
"save",
|
|
901
|
+
"scrub",
|
|
902
|
+
]
|