standkit 0.12.1__tar.gz → 0.12.2__tar.gz

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 (148) hide show
  1. {standkit-0.12.1/standkit.egg-info → standkit-0.12.2}/PKG-INFO +1 -1
  2. {standkit-0.12.1 → standkit-0.12.2}/docs/CHANGELOG.md +54 -0
  3. {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0007-hub-exit-idle-and-plain-restart.md +77 -6
  4. {standkit-0.12.1 → standkit-0.12.2}/pyproject.toml +1 -1
  5. {standkit-0.12.1 → standkit-0.12.2}/standkit/__init__.py +1 -1
  6. {standkit-0.12.1 → standkit-0.12.2/standkit.egg-info}/PKG-INFO +1 -1
  7. {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/SOURCES.txt +2 -0
  8. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/__main__.py +51 -28
  9. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/config.py +6 -1
  10. standkit-0.12.2/standkit_hub/hub_logging.py +153 -0
  11. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/poller.py +13 -0
  12. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/server.py +129 -13
  13. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_exit_idle_restart.py +218 -4
  14. standkit-0.12.2/tests/test_hub_logging.py +198 -0
  15. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_main_elevated.py +18 -11
  16. {standkit-0.12.1 → standkit-0.12.2}/LICENSE +0 -0
  17. {standkit-0.12.1 → standkit-0.12.2}/MANIFEST.in +0 -0
  18. {standkit-0.12.1 → standkit-0.12.2}/README.md +0 -0
  19. {standkit-0.12.1 → standkit-0.12.2}/SECURITY.md +0 -0
  20. {standkit-0.12.1 → standkit-0.12.2}/docs/ARCHITECTURE.md +0 -0
  21. {standkit-0.12.1 → standkit-0.12.2}/docs/BACKLOG.md +0 -0
  22. {standkit-0.12.1 → standkit-0.12.2}/docs/COOKBOOK_LINUX.md +0 -0
  23. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-001-registry-fields-out-of-hub-form.md +0 -0
  24. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-002-http-probe-down-not-explained.md +0 -0
  25. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-003-redis-endpoint-in-extra.md +0 -0
  26. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-004-cookbook-hub-register-form.md +0 -0
  27. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-005-cookbook-hub-side-secrets.md +0 -0
  28. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-006-log-dir-case-and-separator.md +0 -0
  29. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-007-agent-paths-raw-traceback.md +0 -0
  30. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-008-agent-cert-trust-on-hub.md +0 -0
  31. {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/README.md +0 -0
  32. {standkit-0.12.1 → standkit-0.12.2}/docs/HOSTING.md +0 -0
  33. {standkit-0.12.1 → standkit-0.12.2}/docs/README.md +0 -0
  34. {standkit-0.12.1 → standkit-0.12.2}/docs/REMOTE_STANDS.md +0 -0
  35. {standkit-0.12.1 → standkit-0.12.2}/docs/ROADMAP.md +0 -0
  36. {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0001-hosting-backends.md +0 -0
  37. {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0002-k8s-backend.md +0 -0
  38. {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0003-remote-log-browsing.md +0 -0
  39. {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0004-iis-site-scoped-lifecycle.md +0 -0
  40. {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0005-companion-transport-in-mit-core.md +0 -0
  41. {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0006-hub-elevated-restart.md +0 -0
  42. {standkit-0.12.1 → standkit-0.12.2}/docs/research/companion-v2-cicd-thin-agent.md +0 -0
  43. {standkit-0.12.1 → standkit-0.12.2}/projects.sample.json +0 -0
  44. {standkit-0.12.1 → standkit-0.12.2}/setup.cfg +0 -0
  45. {standkit-0.12.1 → standkit-0.12.2}/standkit/adopt.py +0 -0
  46. {standkit-0.12.1 → standkit-0.12.2}/standkit/cli_resolve.py +0 -0
  47. {standkit-0.12.1 → standkit-0.12.2}/standkit/health.py +0 -0
  48. {standkit-0.12.1 → standkit-0.12.2}/standkit/hosting.py +0 -0
  49. {standkit-0.12.1 → standkit-0.12.2}/standkit/lifecycle.py +0 -0
  50. {standkit-0.12.1 → standkit-0.12.2}/standkit/logs.py +0 -0
  51. {standkit-0.12.1 → standkit-0.12.2}/standkit/models.py +0 -0
  52. {standkit-0.12.1 → standkit-0.12.2}/standkit/platform.py +0 -0
  53. {standkit-0.12.1 → standkit-0.12.2}/standkit/registry.py +0 -0
  54. {standkit-0.12.1 → standkit-0.12.2}/standkit/secrets.py +0 -0
  55. {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/dependency_links.txt +0 -0
  56. {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/entry_points.txt +0 -0
  57. {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/requires.txt +0 -0
  58. {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/top_level.txt +0 -0
  59. {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/__init__.py +0 -0
  60. {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/__main__.py +0 -0
  61. {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/audit.py +0 -0
  62. {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/security.py +0 -0
  63. {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/server.py +0 -0
  64. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/__init__.py +0 -0
  65. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/__main__.py +0 -0
  66. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/backend.py +0 -0
  67. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/context.py +0 -0
  68. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/cookbook.py +0 -0
  69. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/ed25519.py +0 -0
  70. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/errors.py +0 -0
  71. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/fsutil.py +0 -0
  72. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/mcp_mutex.py +0 -0
  73. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/patterns.py +0 -0
  74. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/releases.py +0 -0
  75. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/revocations.py +0 -0
  76. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/runner.py +0 -0
  77. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/signature.py +0 -0
  78. {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/state.py +0 -0
  79. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/__init__.py +0 -0
  80. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/agent_control.py +0 -0
  81. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/assets/bpmkit-icon.ico +0 -0
  82. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/assets/icon.png +0 -0
  83. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/client.py +0 -0
  84. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/consent_api.py +0 -0
  85. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/elevated_op.py +0 -0
  86. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/elevation.py +0 -0
  87. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/instance.py +0 -0
  88. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/license_api.py +0 -0
  89. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/logs_browser.py +0 -0
  90. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/mutex.py +0 -0
  91. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/pick_dialog.py +0 -0
  92. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/redis_min.py +0 -0
  93. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/security.py +0 -0
  94. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/shortcut.py +0 -0
  95. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/app.js +0 -0
  96. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/bpmkit-logo-dark.svg +0 -0
  97. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/bpmkit-logo.svg +0 -0
  98. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/cookbook.html +0 -0
  99. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/favicon.png +0 -0
  100. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/favicon.svg +0 -0
  101. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/index.html +0 -0
  102. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/manifest.webmanifest +0 -0
  103. {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/style.css +0 -0
  104. {standkit-0.12.1 → standkit-0.12.2}/tests/test_adopt.py +0 -0
  105. {standkit-0.12.1 → standkit-0.12.2}/tests/test_agent_security.py +0 -0
  106. {standkit-0.12.1 → standkit-0.12.2}/tests/test_agent_server_integration.py +0 -0
  107. {standkit-0.12.1 → standkit-0.12.2}/tests/test_agent_startup_preflight.py +0 -0
  108. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_backend.py +0 -0
  109. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_context.py +0 -0
  110. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_cookbook.py +0 -0
  111. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_fsutil.py +0 -0
  112. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_mcp_mutex.py +0 -0
  113. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_patterns.py +0 -0
  114. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_releases.py +0 -0
  115. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_revocations.py +0 -0
  116. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_runner.py +0 -0
  117. {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_signature.py +0 -0
  118. {standkit-0.12.1 → standkit-0.12.2}/tests/test_health.py +0 -0
  119. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hosting.py +0 -0
  120. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_agent_control.py +0 -0
  121. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_client_tls.py +0 -0
  122. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_companion_api.py +0 -0
  123. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_config.py +0 -0
  124. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_consent_api.py +0 -0
  125. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_elevated_op.py +0 -0
  126. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_elevation.py +0 -0
  127. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_elevation_ui.py +0 -0
  128. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_fast_paint.py +0 -0
  129. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_instance.py +0 -0
  130. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_license_api.py +0 -0
  131. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_logs_browser.py +0 -0
  132. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_mutex.py +0 -0
  133. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_pick_dialog.py +0 -0
  134. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_pwa_and_compact.py +0 -0
  135. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_redis_min.py +0 -0
  136. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_register.py +0 -0
  137. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_server.py +0 -0
  138. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_settings_field_help.py +0 -0
  139. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_shortcut.py +0 -0
  140. {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_single_instance.py +0 -0
  141. {standkit-0.12.1 → standkit-0.12.2}/tests/test_lifecycle.py +0 -0
  142. {standkit-0.12.1 → standkit-0.12.2}/tests/test_logs.py +0 -0
  143. {standkit-0.12.1 → standkit-0.12.2}/tests/test_models.py +0 -0
  144. {standkit-0.12.1 → standkit-0.12.2}/tests/test_no_window.py +0 -0
  145. {standkit-0.12.1 → standkit-0.12.2}/tests/test_platform_sid.py +0 -0
  146. {standkit-0.12.1 → standkit-0.12.2}/tests/test_registry.py +0 -0
  147. {standkit-0.12.1 → standkit-0.12.2}/tests/test_registry_resolver.py +0 -0
  148. {standkit-0.12.1 → standkit-0.12.2}/tests/test_secrets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: standkit
3
- Version: 0.12.1
3
+ Version: 0.12.2
4
4
  Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
5
5
  Author: standkit contributors
6
6
  License-Expression: MIT
@@ -5,6 +5,60 @@
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.12.2] — 2026-09-19
9
+
10
+ ### Исправлено
11
+
12
+ - **Диспетчер выходил по простою при ЖИВЫХ стендах (GAP-383).** Живой инцидент
13
+ владельца 18.09.2026 20:56: хаб завершился штатно (`request_self_shutdown`),
14
+ три работающих стенда остались без управления, на экране была только ошибка
15
+ связи. Предикат простоя `_IdleShutdownWatcher.has_running_stands()` сравнивал
16
+ `process.state` со строкой `"running"`, **которой в `ProbeState` нет вообще**
17
+ (`ok`/`down`/`unknown`/`skipped`) — то есть на любом реальном снапшоте он
18
+ отвечал «стендов нет», и 30-минутный таймер срабатывал независимо от того,
19
+ сколько стендов работает. Набор тестов оставался зелёным, потому что его
20
+ двойник снапшота подставлял то самое выдуманное `"running"`; теперь двойник
21
+ повторяет поля настоящего `StatusSnapshot`, а отдельный тест-сторож следит,
22
+ что значения `"running"` в `ProbeState` по-прежнему нет.
23
+ Тем же гэпом закрыты два способа принять незнание за пустоту: снапшот-ОТКАЗ
24
+ поллера (`_safe_build`: `stands=[]`, `probed=False`, `error=...`) и
25
+ непробованный слепок реестра (`probed=False`, состояния `pending`) теперь
26
+ трактуются как «стенды есть» — ровно как обещали докстринг предиката и
27
+ ADR-0007 («незнание = остаёмся»).
28
+ - **Автовыход при заменённых под живым процессом файлах пакета (GAP-383).**
29
+ `pip install standkit` поверх работающего диспетчера (владелец сделал это за
30
+ два часа до инцидента) оставляет на диске новый код, а в памяти старый.
31
+ Детект рассинхрона у хаба был (ADR-0007 §4), но автовыход про него не знал.
32
+ Теперь при расхождении версии «с диска» и «из памяти» диспетчер **не
33
+ выходит** по простою: перезапуск остаётся действием человека
34
+ (`POST /api/hub/restart` с открытой страницы), молчаливого самоперезапуска
35
+ фонового процесса не вводится. Ошибка чтения версии — тихий фолбэк, она не
36
+ объявляет рассинхрон и не запрещает автовыход навсегда.
37
+ - **Усыновлённые и поднятые мимо диспетчера стенды считаются живыми
38
+ (GAP-385).** Условие простоя теперь «нет НИ ОДНОГО стенда, живого ПО ФАКТУ
39
+ пробы (`ProbeState.OK`, то есть по порту/pid)», а не «нет запущенных нами».
40
+ Стенд с мёртвым родителем (`standkit.adopt`, бейдж `process.external`) —
41
+ такой же повод остаться: потерять управление им ничуть не лучше, чем своим.
42
+
43
+ ### Добавлено
44
+
45
+ - **Лог диспетчера в файл (GAP-384).** Новый модуль `standkit_hub/hub_logging.py`:
46
+ `resolve_log_dir()` (`%LOCALAPPDATA%\BPMkit\logs\hub.log`, на POSIX —
47
+ `$XDG_STATE_HOME`/`~/.local/state`) и ротация 2 МБ × 5. Причина слепоты при
48
+ разборе инцидента была структурной: ярлык запускает диспетчер через
49
+ `pythonw.exe`, у которого `sys.stderr` не подключён никуда (и может быть
50
+ `None`), а все объяснения — «простой N минут», «стоп-запрос», «перехват
51
+ порта» — писались через `print(..., file=sys.stderr)` и существовали ровно
52
+ на время своего вывода. Теперь в файл пишутся: старт и версия, запрос
53
+ штатного выхода, стоп-запрос (включая протухший) и **факты, по которым
54
+ принято решение об автовыходе** — `sse_client_count`, `has_running_stands`,
55
+ `snapshot_probed`, `snapshot_error`; отказ фонового опроса
56
+ (`poller._safe_build`) логируется с трейсбеком; необработанное исключение
57
+ процесса уходит в лог через `sys.excepthook`. Все 16 `print(..., file=sys.stderr)`
58
+ в `standkit_hub/__main__.py` переведены на `logging`. Неудача настройки лога
59
+ старт НЕ прерывает: диспетчер без лога хуже, но несравнимо лучше
60
+ незапустившегося.
61
+
8
62
  ## [0.12.1] — 2026-09-17
9
63
 
10
64
  ### Добавлено
@@ -1,7 +1,8 @@
1
1
  # ADR-0007 — Выход из диспетчера, автовыход по простою и перезапуск без повышения прав
2
2
 
3
3
  - Статус: **Принято / реализовано** (v0.12.0, 17.09.2026; постановка владельца Д-3 и
4
- GAP-276 dev-репо BPMkit).
4
+ GAP-276 dev-репо BPMkit). **Уточнено v0.12.2 (19.09.2026) по живому инциденту
5
+ 18.09.2026 20:56** — GAP-383/384/385, см. раздел «Уточнение 19.09.2026» ниже.
5
6
  - Контекст-владелец: Владимир Терновский
6
7
  - Связано: [ADR-0006](0006-hub-elevated-restart.md) (перезапуск через UAC — вся машинерия
7
8
  перезапуска переиспользуется здесь), GAP-229/GAP-284 (именованный мьютекс для
@@ -61,8 +62,8 @@
61
62
 
62
63
  | | вкладка открыта | вкладка закрыта |
63
64
  |---|---|---|
64
- | **есть running-стенды** | работаем | работаем |
65
- | **нет running-стендов** | работаем | **простой** |
65
+ | **есть живые стенды** (`ProbeState.OK` по порту/pid) | работаем | работаем |
66
+ | **нет живых стендов** | работаем | **простой** |
66
67
 
67
68
  Почему И, а не ИЛИ. Закрытая вкладка при живых стендах — штатная работа: стенды подняли
68
69
  через диспетчер и ушли ими пользоваться; выйти сейчас значит потерять над ними управление
@@ -74,9 +75,11 @@
74
75
  время последнего запроса: SSE держится ровно пока страница открыта, и это единственный
75
76
  сигнал, который не приходится угадывать по таймерам опроса.
76
77
 
77
- **Незнание трактуется в пользу «остаться»**: поллера нет или он ещё не отдал первый
78
- снапшот — считаем, что стенды есть. Ошибочно оставшийся процесс человек закроет кнопкой;
79
- ошибочно закрывшийся уносит с собой управление живыми стендами.
78
+ **Незнание трактуется в пользу «остаться»**: поллера нет; он ещё не отдал первый снапшот;
79
+ снапшот пришёл с `error` (отказ `_safe_build`); снапшот непробованный (`probed=False`);
80
+ запись стенда не разобралась — во всех этих случаях считаем, что стенды есть. Ошибочно
81
+ оставшийся процесс человек закроет кнопкой; ошибочно закрывшийся уносит с собой
82
+ управление живыми стендами. (Полный перечень и его цена — «Уточнение 19.09.2026».)
80
83
 
81
84
  **`--desktop` — особый случай**: вкладки нет вовсе, окно и есть процесс, SSE-поток может
82
85
  пересоздаваться при перерисовке. Признак «никто не смотрит» там недостоверен, поэтому
@@ -135,6 +138,74 @@ elevated-перезапуск МЕНЯЕТ права процесса и пот
135
138
  «Основные», `0` выключает.
136
139
  - Деинсталляция после «Выхода» проверяется на VM (exe-поставка), а не на хосте издателя.
137
140
 
141
+
142
+ ## Уточнение 19.09.2026 (v0.12.2) — чего этому ADR не хватило в бою
143
+
144
+ Живой инцидент владельца **18.09.2026 20:56**: диспетчер вышел по простою, три работающих
145
+ стенда остались без управления, на экране была только ошибка связи. Решения этого ADR были
146
+ верны; сломалось то, что ниже.
147
+
148
+ ### 1. Предикат простоя был тождественно ложным (GAP-383)
149
+
150
+ `has_running_stands()` сравнивал `process.state` со строкой `"running"`, **которой в
151
+ `ProbeState` нет вообще** (`ok`/`down`/`unknown`/`skipped`). На любом реальном снапшоте
152
+ предикат отвечал «стендов нет», и конъюнкция из §2 вырождалась в одно слагаемое: таймер
153
+ срабатывал ровно через 30 минут после закрытия вкладки, сколько бы стендов ни работало.
154
+
155
+ Почему это пережило тесты: двойник снапшота в наборе сам подставлял то же выдуманное
156
+ `"running"`. **Двойник, который беднее оригинала, проверяет не тот объект, что работает у
157
+ пользователя** — теперь `_FakeSnapshot` повторяет поля настоящего `StatusSnapshot`
158
+ (`probed`, `error`), а тест-сторож `test_probe_state_has_no_running_value` следит, чтобы
159
+ значение `"running"` не «вернулось» в код вместе с чьей-нибудь правкой.
160
+
161
+ ### 2. Снапшот-отказ читался как пустота (GAP-383)
162
+
163
+ `poller._safe_build` при любом сбое опроса честно возвращает `StatusSnapshot(stands=[],
164
+ probed=False, error=...)`. Формально это «я не знаю», фактически — пустой список, который
165
+ предикат принимал за «стендов нет». Декларация «незнание = остаёмся» в §2 существовала, но
166
+ в коде покрывала только `poller is None` и `snapshot is None`.
167
+
168
+ Теперь она покрывает весь перечень (см. таблицу выше). Цена ошибки несимметрична, и это
169
+ единственный аргумент, который здесь имеет значение: лишний живой процесс закрывается
170
+ кнопкой, лишнее завершение уносит управление стендами и объяснить себя не может.
171
+
172
+ ### 3. Автовыход не знал про подмену файлов под живым процессом (GAP-383)
173
+
174
+ За два часа до инцидента владелец сделал `pip install standkit` поверх работающего
175
+ диспетчера. Детект рассинхрона у хаба уже был (§4), но касался только страницы. Автовыход
176
+ шёл прежним путём.
177
+
178
+ **Решение: при рассинхроне версий (диск новее памяти) по простою НЕ выходить.** Плашка
179
+ «перезапустите» и `POST /api/hub/restart` остаются действием ЧЕЛОВЕКА. Молчаливый
180
+ самоперезапуск фонового процесса отвергнут: он относится к тому же классу действий,
181
+ что и разбираемый инцидент — процесс без окна принимает за пользователя решение,
182
+ последствия которого тот увидит как «всё пропало».
183
+
184
+ ### 4. Усыновлённые стенды не считались живыми (GAP-385)
185
+
186
+ Условие простоя теперь — «нет НИ ОДНОГО стенда, живого **по факту пробы** (порт/pid)», а не
187
+ «нет запущенных нами». Стенд с мёртвым родителем (`standkit.adopt`, бейдж
188
+ `process.external`) — такой же повод остаться: он работает, и потерять управление им ничуть
189
+ не лучше, чем своим. Это прямое следствие обещания §1 «детей не трогаем»: если выход
190
+ штатно оставляет стенды сиротами, то сироты обязаны удерживать следующий выход.
191
+
192
+ ### 5. Причины завершения не доживали до разбора (GAP-384)
193
+
194
+ Диспетчер живёт под `pythonw.exe`, у которого `sys.stderr` не подключён никуда (и бывает
195
+ `None`), а все объяснения этого ADR писались через `print(..., file=sys.stderr)`. Самое
196
+ громкое решение продукта — завершение процесса — было событием, о котором назавтра нельзя
197
+ узнать ничего.
198
+
199
+ Теперь есть `standkit_hub/hub_logging.py` (`%LOCALAPPDATA%\BPMkit\logs\hub.log`, ротация
200
+ 2 МБ × 5), и в него пишутся старт с версией, запрос штатного выхода, стоп-запрос, отказ
201
+ фонового опроса с трейсбеком, необработанное исключение — и, главное, **факты, по которым
202
+ принято решение об автовыходе** (`sse_client_count`, `has_running_stands`,
203
+ `snapshot_probed`, `snapshot_error`), а не только сам факт выхода. Решение, записавшее свои
204
+ основания, опровергается за минуту; решение, записавшее вывод, разбирается сутки.
205
+
206
+ Настройка лога не имеет права уронить старт: любая беда — тихий отказ, логгер остаётся
207
+ рабочим объектом.
208
+
138
209
  ## Отвергнутые варианты
139
210
 
140
211
  - **Иконка в трее с пунктом «Выход».** Отвергнута владельцем ранее и не пересматривалась:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "standkit"
7
- version = "0.12.1"
7
+ version = "0.12.2"
8
8
  description = "Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -6,6 +6,6 @@ standkit — свободное (MIT) ядро управления жизнен
6
6
  и docs/ARCHITECTURE.md в корне репозитория.
7
7
  """
8
8
 
9
- __version__ = "0.12.1"
9
+ __version__ = "0.12.2"
10
10
 
11
11
  __all__ = ["__version__"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: standkit
3
- Version: 0.12.1
3
+ Version: 0.12.2
4
4
  Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
5
5
  Author: standkit contributors
6
6
  License-Expression: MIT
@@ -74,6 +74,7 @@ standkit_hub/config.py
74
74
  standkit_hub/consent_api.py
75
75
  standkit_hub/elevated_op.py
76
76
  standkit_hub/elevation.py
77
+ standkit_hub/hub_logging.py
77
78
  standkit_hub/instance.py
78
79
  standkit_hub/license_api.py
79
80
  standkit_hub/logs_browser.py
@@ -123,6 +124,7 @@ tests/test_hub_exit_idle_restart.py
123
124
  tests/test_hub_fast_paint.py
124
125
  tests/test_hub_instance.py
125
126
  tests/test_hub_license_api.py
127
+ tests/test_hub_logging.py
126
128
  tests/test_hub_logs_browser.py
127
129
  tests/test_hub_main_elevated.py
128
130
  tests/test_hub_mutex.py
@@ -35,15 +35,21 @@ import webbrowser
35
35
  from pathlib import Path
36
36
  from typing import Optional
37
37
 
38
+ from standkit import __version__ as _standkit_version
38
39
  from standkit.platform import current_user_name, current_user_sid, is_elevated
39
40
  from standkit_hub import instance as _instance
40
41
  from standkit_hub.config import HubConfig
42
+ from standkit_hub import hub_logging as _hub_logging
41
43
  from standkit_hub.mutex import acquire_hub_mutex
42
44
  from standkit_hub.elevation import ReparseGuardError, read_handoff, refusal_text, write_result_atomic
43
45
  from standkit_hub.security import InsecureBindError, generate_session_token
44
46
  from standkit_hub.server import DEFAULT_HUB_PORT, HubAlreadyRunning, bind_hub_server
45
47
  from standkit_hub.shortcut import install_desktop_shortcut, uninstall_desktop_shortcut
46
48
 
49
+ # Лог диспетчера (GAP-384). Под pythonw.exe stderr не подключён никуда,
50
+ # поэтому всё, что объясняет отказ или завершение, идёт в файл.
51
+ _log = _hub_logging.logger()
52
+
47
53
  # Сколько в сумме ждать строгий bind перехваченного порта (GAP-311 В4/В6):
48
54
  # порт может освободиться не мгновенно даже после успешной остановки старого
49
55
  # процесса — повторяем bind, а не пытаемся один раз.
@@ -102,7 +108,7 @@ def _write_relaunch_result(result_file: str, *, status: str, message: str = "",
102
108
  try:
103
109
  write_result_atomic(Path(result_file), payload)
104
110
  except (OSError, ReparseGuardError) as exc:
105
- print(f"[standkit-hub] не удалось записать результат перезапуска: {exc}", file=sys.stderr)
111
+ _log.warning(f"не удалось записать результат перезапуска: {exc}")
106
112
 
107
113
 
108
114
  def _describe_elevation(value) -> str:
@@ -144,22 +150,21 @@ def _takeover_running_instance(
144
150
  # напрямую; сейчас это просто впустую потраченное время пользователя.
145
151
  # Отказ немедленно, без ожидания.
146
152
  reason = "файл состояния работающего диспетчера не найден — закройте его вручную и запустите снова"
147
- print(f"[standkit-hub] {reason}", file=sys.stderr)
153
+ _log.warning(f"{reason}")
148
154
  return False, reason
149
155
 
150
- print(
151
- f"[standkit-hub] перехватываю порт {exc.port} у работающего диспетчера "
156
+ _log.warning(f"перехватываю порт {exc.port} у работающего диспетчера "
152
157
  f"(pid {state.pid}, права администратора: {_describe_elevation(state.elevated)})"
153
158
  )
154
159
  ok, reason = _instance.stop_running_instance(state, run_dir=run_dir, requester_pid=os.getpid())
155
160
  if not ok:
156
161
  message = reason or f"не удалось остановить процесс {state.pid}"
157
- print(f"[standkit-hub] {message} — перехват отменён", file=sys.stderr)
162
+ print(f"[standkit-hub] {message} — перехват отменён")
158
163
  return False, message
159
164
 
160
165
  if not _instance.wait_port_released(exc.host, exc.port):
161
166
  reason = f"порт {exc.port} так и не освободился — перехват отменён"
162
- print(f"[standkit-hub] {reason}", file=sys.stderr)
167
+ _log.warning(f"{reason}")
163
168
  return False, reason
164
169
  return True, ""
165
170
 
@@ -190,12 +195,11 @@ def _warn_if_run_dir_outside_profile(run_dir: Path) -> None:
190
195
  try:
191
196
  resolved.relative_to(home)
192
197
  except ValueError:
193
- print(
194
- f"[standkit-hub] ВНИМАНИЕ: run_dir ({resolved}) находится вне домашнего "
198
+ _log.warning(
199
+ f"ВНИМАНИЕ: run_dir ({resolved}) находится вне домашнего "
195
200
  f"каталога пользователя ({home}) — файлы с правами 0o600 (handoff, состояние) "
196
201
  "на Windows не защищены ACL профиля; убедитесь, что каталог недоступен другим "
197
- "локальным учётным записям",
198
- file=sys.stderr,
202
+ "локальным учётным записям"
199
203
  )
200
204
 
201
205
 
@@ -297,13 +301,34 @@ def main(argv: list[str] | None = None) -> int:
297
301
  )
298
302
  args = parser.parse_args(argv)
299
303
 
304
+ # Лог поднимаем ПЕРВЫМ делом после разбора аргументов — до mutex/bind/
305
+ # state и до любой ветки, которая может завершиться отказом: инцидент
306
+ # 18.09.2026 показал, что дороже всего обходятся именно те сообщения,
307
+ # которые процесс успел бы написать ДО того, как что-то пошло не так.
308
+ # Неудача настройки лога старт НЕ прерывает (см. hub_logging.setup_logging).
309
+ _log_path = _hub_logging.setup_logging()
310
+ _log.info(
311
+ "старт standkit-hub: версия=%s python=%s pid=%s лог=%s",
312
+ _standkit_version, sys.version.split()[0], os.getpid(), _log_path or "(нет)",
313
+ )
314
+
315
+ # Необработанное исключение обязано остаться в логе, а не исчезнуть вместе
316
+ # с невидимым stderr: без этого «диспетчер просто пропал» — всё, что
317
+ # человек может сообщить о падении.
318
+ def _log_unhandled(exc_type, exc_value, exc_tb):
319
+ _log.critical("необработанное исключение — процесс завершается",
320
+ exc_info=(exc_type, exc_value, exc_tb))
321
+ sys.__excepthook__(exc_type, exc_value, exc_tb)
322
+
323
+ sys.excepthook = _log_unhandled
324
+
300
325
  if args.elevated_op:
301
326
  # ДО любых bind/mutex/state/handoff: это одноразовый процесс "выполнить
302
327
  # и выйти", HTTP-сервер ему не нужен вовсе (см. standkit_hub.elevated_op).
303
328
  from standkit_hub import elevated_op as _elevated_op
304
329
 
305
330
  if not args.stand or not args.result_file:
306
- print("[standkit-hub] --elevated-op требует --stand и --result-file", file=sys.stderr)
331
+ print("[standkit-hub] --elevated-op требует --stand и --result-file")
307
332
  return 1
308
333
  config_path = Path(args.config) if args.config else None
309
334
  return _elevated_op.run(
@@ -316,7 +341,7 @@ def main(argv: list[str] | None = None) -> int:
316
341
 
317
342
  if args.install_shortcut or args.uninstall_shortcut:
318
343
  result = install_desktop_shortcut() if args.install_shortcut else uninstall_desktop_shortcut()
319
- print(f"[standkit-hub] {result.message}")
344
+ _log.warning(f"{result.message}")
320
345
  return 0 if result.ok else 1
321
346
 
322
347
  our_sid = current_user_sid()
@@ -333,7 +358,7 @@ def main(argv: list[str] | None = None) -> int:
333
358
  if args.initiator_sid:
334
359
  if our_sid and our_sid != args.initiator_sid:
335
360
  message = refusal_text(current_user_name())
336
- print(f"[standkit-hub] {message}", file=sys.stderr)
361
+ print(f"[standkit-hub] {message}")
337
362
  if args.result_file:
338
363
  _write_relaunch_result(
339
364
  args.result_file, status="refused", message=message, user=current_user_name()
@@ -356,7 +381,7 @@ def main(argv: list[str] | None = None) -> int:
356
381
  try:
357
382
  config.ensure_registry_dir()
358
383
  except OSError as exc:
359
- print(f"[standkit-hub] не удалось подготовить папку реестра: {exc}", file=sys.stderr)
384
+ _log.warning(f"не удалось подготовить папку реестра: {exc}")
360
385
 
361
386
  run_dir = config.resolve_run_dir()
362
387
  _warn_if_run_dir_outside_profile(run_dir)
@@ -369,10 +394,9 @@ def main(argv: list[str] | None = None) -> int:
369
394
  if args.session_token_file:
370
395
  session_token = read_handoff(Path(args.session_token_file)) or ""
371
396
  if not session_token:
372
- print(
373
- "[standkit-hub] сессию предыдущего экземпляра перенести не удалось "
374
- "(файл передачи отсутствует или протух) — открывайте дашборд заново по ярлыку",
375
- file=sys.stderr,
397
+ _log.warning(
398
+ "сессию предыдущего экземпляра перенести не удалось "
399
+ "(файл передачи отсутствует или протух) — открывайте дашборд заново по ярлыку"
376
400
  )
377
401
  if not session_token:
378
402
  session_token = generate_session_token()
@@ -389,7 +413,7 @@ def main(argv: list[str] | None = None) -> int:
389
413
  def _report_port_busy(requested: int, exc: OSError) -> None:
390
414
  # Печатаем ДО повторного bind'а: пользователь должен понимать, почему
391
415
  # адрес в консоли/закладке вдруг отличается от привычного.
392
- print(f"[standkit-hub] порт {requested} занят ({exc.strerror or exc}) — беру свободный", file=sys.stderr)
416
+ _log.warning(f"порт {requested} занят ({exc.strerror or exc}) — беру свободный")
393
417
 
394
418
  def _bind():
395
419
  return bind_hub_server(
@@ -439,7 +463,7 @@ def main(argv: list[str] | None = None) -> int:
439
463
  httpd = _bind_with_retries(_bind_strict)
440
464
  except (HubAlreadyRunning, InsecureBindError, OSError) as exc2:
441
465
  message = f"перехват не удался: {exc2}"
442
- print(f"[standkit-hub] {message}", file=sys.stderr)
466
+ _log.warning(f"{message}")
443
467
  if args.result_file:
444
468
  _write_relaunch_result(args.result_file, status="failed", message=message)
445
469
  return 1
@@ -453,13 +477,13 @@ def main(argv: list[str] | None = None) -> int:
453
477
  # не удалось остановить и т.п.), если она есть, — а не
454
478
  # обобщённый текст.
455
479
  message = takeover_reason or "перехват порта не выполнен: работающий экземпляр перехвату не подлежит"
456
- print(f"[standkit-hub] {message}", file=sys.stderr)
480
+ _log.warning(f"{message}")
457
481
  _write_relaunch_result(args.result_file, status="failed", message=message)
458
482
  return 1
459
483
  else:
460
484
  return _open_running_instance(exc, no_browser=args.no_browser)
461
485
  except InsecureBindError as exc:
462
- print(f"[standkit-hub] {exc}", file=sys.stderr)
486
+ _log.warning(f"{exc}")
463
487
  if args.result_file:
464
488
  _write_relaunch_result(args.result_file, status="failed", message=str(exc))
465
489
  return 1
@@ -485,7 +509,7 @@ def main(argv: list[str] | None = None) -> int:
485
509
  message = f"порт {args.port} занят другим приложением"
486
510
  else:
487
511
  message = f"не удалось занять {args.host}:{args.port} — {exc}"
488
- print(f"[standkit-hub] {message}", file=sys.stderr)
512
+ _log.warning(f"{message}")
489
513
  if args.result_file:
490
514
  _write_relaunch_result(args.result_file, status="failed", message=message)
491
515
  return 1
@@ -499,7 +523,7 @@ def main(argv: list[str] | None = None) -> int:
499
523
 
500
524
  actual_port = httpd.server_address[1]
501
525
  if args.port and actual_port != args.port:
502
- print(f"[standkit-hub] порт {args.port} занят, слушаю {actual_port}")
526
+ _log.warning(f"порт {args.port} занят, слушаю {actual_port}")
503
527
  # Файл состояния — чтобы СЛЕДУЮЩИЙ запуск знал, кого он видит на порту
504
528
  # (в т.ч. с правами администратора тот процесс или нет).
505
529
  elevated = is_elevated()
@@ -509,7 +533,7 @@ def main(argv: list[str] | None = None) -> int:
509
533
  _instance.current_state(args.host, actual_port, elevated=elevated, user_sid=our_sid),
510
534
  )
511
535
  except (OSError, ReparseGuardError) as exc:
512
- print(f"[standkit-hub] не удалось записать файл состояния: {exc}", file=sys.stderr)
536
+ print(f"[standkit-hub] не удалось записать файл состояния: {exc}")
513
537
 
514
538
  # Д-3: штатный выход (кнопка «Выход» и автовыход по простою) обязан убрать
515
539
  # файл состояния ДО завершения процесса — иначе следующий запуск увидит
@@ -605,9 +629,8 @@ def _serve_inner(httpd, *, args, url: str) -> int:
605
629
  try:
606
630
  import webview # type: ignore
607
631
  except ImportError:
608
- print(
609
- "[standkit-hub] pywebview не установлен (pip install standkit[desktop]) — открываю в системном браузере",
610
- file=sys.stderr,
632
+ _log.warning(
633
+ "pywebview не установлен (pip install standkit[desktop]) — открываю в системном браузере"
611
634
  )
612
635
  if not args.no_browser:
613
636
  webbrowser.open(url)
@@ -266,11 +266,16 @@ class HubConfig:
266
266
  log_dir: str = ""
267
267
  refresh_interval_sec: int = _DEFAULT_REFRESH_INTERVAL_SEC
268
268
  # Автовыход по простою (Д-3/GAP-276), МИНУТЫ; 0 — выключено. Простой =
269
- # «нет ни одного открытого SSE-клиента И ни одного running-стенда»: первое
269
+ # «нет ни одного открытого SSE-клиента И ни одного ЖИВОГО стенда»: первое
270
270
  # означает «дашборд никто не смотрит», второе — «диспетчер сейчас ничем не
271
271
  # управляет». Оба условия обязательны вместе: закрытая вкладка при живых
272
272
  # стендах — норма (стенды поднимали через диспетчер и ждут), а открытая
273
273
  # вкладка без стендов — человек смотрит на пустой список, но смотрит.
274
+ #
275
+ # ЖИВОЙ — по факту пробы (``ProbeState.OK``: порт/pid), включая стенды,
276
+ # поднятые мимо диспетчера и усыновлённые (GAP-385). Любое незнание
277
+ # (отказ опроса, непробованный слепок) трактуется как «стенды есть»
278
+ # (GAP-383, ADR-0007): цена лишнего завершения несимметрично выше.
274
279
  idle_shutdown_min: int = _DEFAULT_IDLE_SHUTDOWN_MIN
275
280
  # light | dark | auto (см. normalize_theme). Подставляется сервером прямо
276
281
  # в атрибут data-theme отдаваемого index.html — тема применяется ДО
@@ -0,0 +1,153 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Лог диспетчера в ФАЙЛ (GAP-384) — STDLIB-ONLY, как и весь standkit_hub.
4
+
5
+ ЗАЧЕМ ЭТОТ МОДУЛЬ ВООБЩЕ ПОЯВИЛСЯ. Инцидент 18.09.2026 20:56: диспетчер вышел
6
+ сам (автовыход по простою при живых стендах, GAP-383), три стенда осиротели,
7
+ владелец увидел на экране только ошибку связи. Разбирать было нечего.
8
+
9
+ Причина слепоты структурная, а не случайная: ярлык запускает диспетчер через
10
+ ``pythonw.exe`` — процесс БЕЗ консоли, у которого ``sys.stderr`` не подключён
11
+ никуда (а на Windows он может быть и ``None``). Все объяснения диспетчера о
12
+ том, почему он завершается — «простой N минут», «получен стоп-запрос»,
13
+ «перехват порта» — были написаны через ``print(..., file=sys.stderr)``, то
14
+ есть существовали ровно на время своего вывода и никуда не попадали.
15
+
16
+ Отсюда правило, которое этот модуль обслуживает: **всё, что объясняет
17
+ завершение процесса или необработанный отказ, пишется сюда, а не в stderr.**
18
+ Лог — единственный свидетель для процесса, у которого нет ни окна, ни консоли,
19
+ ни того, кто смотрел бы на них в 20:56.
20
+
21
+ Ротация обязательна и не обсуждается: забытый диспетчер живёт неделями, и
22
+ неограниченный файл однажды становится второй аварией поверх первой.
23
+
24
+ Настройка логирования НЕ ИМЕЕТ ПРАВА уронить старт. Диспетчер без лога хуже
25
+ диспетчера с логом, но несравнимо лучше не запустившегося: любая беда при
26
+ создании каталога/файла — тихий отказ с ``None``, логгер при этом остаётся
27
+ рабочим объектом, и все вызовы выше по коду продолжают быть безопасными.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import logging.handlers
33
+ import os
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import Optional
37
+
38
+ # Имя логгера — общее для всего диспетчера: и сервер, и поллер, и __main__
39
+ # пишут в один файл, потому что разбирают их вместе («что было в 20:56»), а не
40
+ # по отдельности.
41
+ LOGGER_NAME = "standkit_hub"
42
+
43
+ LOG_FILE_NAME = "hub.log"
44
+
45
+ # 2 МБ × 5 — заведомо больше любого разумного разбора и заведомо меньше
46
+ # «файл съел диск у человека, который про диспетчер забыл».
47
+ MAX_BYTES = 2 * 1024 * 1024
48
+ BACKUP_COUNT = 5
49
+
50
+ _LOG_FORMAT = "%(asctime)s %(levelname)-7s [%(name)s] %(message)s"
51
+
52
+
53
+ def logger() -> logging.Logger:
54
+ """Логгер диспетчера. Всегда возвращает рабочий объект — см. докстринг модуля."""
55
+ return logging.getLogger(LOGGER_NAME)
56
+
57
+
58
+ def resolve_log_dir(explicit: "Optional[Path | str]" = None) -> Path:
59
+ """
60
+ Каталог лога. Явный аргумент > ``%LOCALAPPDATA%`` > платформенный дефолт.
61
+
62
+ Дефолт ОБЯЗАН быть при любом состоянии окружения: «не смогли определить
63
+ каталог» здесь означает «следующий инцидент снова разбирать нечем».
64
+ Поэтому последний фолбэк — домашний каталог, который есть всегда.
65
+ """
66
+ if explicit:
67
+ return Path(explicit).expanduser()
68
+
69
+ local_appdata = os.environ.get("LOCALAPPDATA")
70
+ if local_appdata:
71
+ return Path(local_appdata) / "BPMkit" / "logs"
72
+
73
+ if sys.platform == "win32":
74
+ # Windows без LOCALAPPDATA — экзотика (служба, урезанный профиль), но
75
+ # молчать в ней нельзя ровно так же, как и в обычном сеансе.
76
+ return Path.home() / "AppData" / "Local" / "BPMkit" / "logs"
77
+
78
+ # POSIX: тот же смысл, что у %LOCALAPPDATA% — состояние приложения.
79
+ state_home = os.environ.get("XDG_STATE_HOME")
80
+ if state_home:
81
+ return Path(state_home) / "BPMkit" / "logs"
82
+ return Path.home() / ".local" / "state" / "BPMkit" / "logs"
83
+
84
+
85
+ def resolve_log_path(explicit_dir: "Optional[Path | str]" = None) -> Path:
86
+ """Полный путь файла лога (``<каталог>/hub.log``)."""
87
+ return resolve_log_dir(explicit_dir) / LOG_FILE_NAME
88
+
89
+
90
+ def setup_logging(*, log_dir: "Optional[Path | str]" = None,
91
+ level: int = logging.INFO,
92
+ force: bool = False) -> Optional[Path]:
93
+ """
94
+ Подключает ротируемый файловый обработчик. Возвращает путь лога или
95
+ ``None``, если настроить не удалось (старт при этом НЕ прерывается).
96
+
97
+ ``force`` — пересобрать обработчики (нужен тестам и повторному запуску в
98
+ одном процессе). Без него повторный вызов идемпотентен: второй файловый
99
+ обработчик означал бы дублирование каждой строки.
100
+ """
101
+ log = logger()
102
+ if force:
103
+ reset_logging()
104
+ elif any(isinstance(h, logging.handlers.RotatingFileHandler) for h in log.handlers):
105
+ return getattr(log, "_standkit_log_path", None)
106
+
107
+ log.setLevel(level)
108
+ # Диспетчер — не библиотека: свои записи он не отдаёт наверх, где их
109
+ # подхватил бы чей-нибудь basicConfig и снова увёл в мёртвый stderr.
110
+ log.propagate = False
111
+
112
+ path = resolve_log_path(log_dir)
113
+ try:
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ handler = logging.handlers.RotatingFileHandler(
116
+ path, maxBytes=MAX_BYTES, backupCount=BACKUP_COUNT, encoding="utf-8")
117
+ except OSError:
118
+ # Каталог занят файлом, нет прав, диск полон — молча остаёмся без
119
+ # файла. Ронять из-за лога процесс, ради живучести которого лог и
120
+ # заводился, было бы прямым противоречием.
121
+ return None
122
+
123
+ handler.setFormatter(logging.Formatter(_LOG_FORMAT))
124
+ log.addHandler(handler)
125
+
126
+ # Консоль — ТОЛЬКО если она есть (обычный python.exe при отладке). Под
127
+ # pythonw.exe ``sys.stderr`` бывает ``None``, и StreamHandler(None) сам
128
+ # стал бы источником отказов в каждой записи.
129
+ if sys.stderr is not None:
130
+ stream = logging.StreamHandler(sys.stderr)
131
+ stream.setFormatter(logging.Formatter(_LOG_FORMAT))
132
+ log.addHandler(stream)
133
+
134
+ setattr(log, "_standkit_log_path", path)
135
+ return path
136
+
137
+
138
+ def current_log_path() -> Optional[Path]:
139
+ """Путь активного лога или ``None`` — то, что показывает ``self_check``."""
140
+ return getattr(logger(), "_standkit_log_path", None)
141
+
142
+
143
+ def reset_logging() -> None:
144
+ """Снимает обработчики (тесты; повторная настройка в одном процессе)."""
145
+ log = logger()
146
+ for handler in list(log.handlers):
147
+ log.removeHandler(handler)
148
+ try:
149
+ handler.close()
150
+ except Exception: # noqa: BLE001 - уборка не имеет права падать
151
+ pass
152
+ if hasattr(log, "_standkit_log_path"):
153
+ delattr(log, "_standkit_log_path")