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
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
+ ]