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.
- {standkit-0.12.1/standkit.egg-info → standkit-0.12.2}/PKG-INFO +1 -1
- {standkit-0.12.1 → standkit-0.12.2}/docs/CHANGELOG.md +54 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0007-hub-exit-idle-and-plain-restart.md +77 -6
- {standkit-0.12.1 → standkit-0.12.2}/pyproject.toml +1 -1
- {standkit-0.12.1 → standkit-0.12.2}/standkit/__init__.py +1 -1
- {standkit-0.12.1 → standkit-0.12.2/standkit.egg-info}/PKG-INFO +1 -1
- {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/SOURCES.txt +2 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/__main__.py +51 -28
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/config.py +6 -1
- standkit-0.12.2/standkit_hub/hub_logging.py +153 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/poller.py +13 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/server.py +129 -13
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_exit_idle_restart.py +218 -4
- standkit-0.12.2/tests/test_hub_logging.py +198 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_main_elevated.py +18 -11
- {standkit-0.12.1 → standkit-0.12.2}/LICENSE +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/MANIFEST.in +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/README.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/SECURITY.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/ARCHITECTURE.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/BACKLOG.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/COOKBOOK_LINUX.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-001-registry-fields-out-of-hub-form.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-002-http-probe-down-not-explained.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-003-redis-endpoint-in-extra.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-004-cookbook-hub-register-form.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-005-cookbook-hub-side-secrets.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-006-log-dir-case-and-separator.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-007-agent-paths-raw-traceback.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/GAP-008-agent-cert-trust-on-hub.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/GAPs/README.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/HOSTING.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/README.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/REMOTE_STANDS.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/ROADMAP.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0001-hosting-backends.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0002-k8s-backend.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0003-remote-log-browsing.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0004-iis-site-scoped-lifecycle.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0005-companion-transport-in-mit-core.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/adr/0006-hub-elevated-restart.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/docs/research/companion-v2-cicd-thin-agent.md +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/projects.sample.json +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/setup.cfg +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/adopt.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/cli_resolve.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/health.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/hosting.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/lifecycle.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/logs.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/models.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/platform.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/registry.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit/secrets.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/dependency_links.txt +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/entry_points.txt +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/requires.txt +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit.egg-info/top_level.txt +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/__init__.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/__main__.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/audit.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/security.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_agent/server.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/__init__.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/__main__.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/backend.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/context.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/cookbook.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/ed25519.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/errors.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/fsutil.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/mcp_mutex.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/patterns.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/releases.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/revocations.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/runner.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/signature.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_companion/state.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/__init__.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/agent_control.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/assets/bpmkit-icon.ico +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/assets/icon.png +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/client.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/consent_api.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/elevated_op.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/elevation.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/instance.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/license_api.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/logs_browser.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/mutex.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/pick_dialog.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/redis_min.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/security.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/shortcut.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/app.js +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/bpmkit-logo-dark.svg +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/bpmkit-logo.svg +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/cookbook.html +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/favicon.png +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/favicon.svg +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/index.html +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/manifest.webmanifest +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/standkit_hub/web/style.css +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_adopt.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_agent_security.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_agent_server_integration.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_agent_startup_preflight.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_backend.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_context.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_cookbook.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_fsutil.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_mcp_mutex.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_patterns.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_releases.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_revocations.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_runner.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_companion_signature.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_health.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hosting.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_agent_control.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_client_tls.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_companion_api.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_config.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_consent_api.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_elevated_op.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_elevation.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_elevation_ui.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_fast_paint.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_instance.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_license_api.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_logs_browser.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_mutex.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_pick_dialog.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_pwa_and_compact.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_redis_min.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_register.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_server.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_settings_field_help.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_shortcut.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_hub_single_instance.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_lifecycle.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_logs.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_models.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_no_window.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_platform_sid.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_registry.py +0 -0
- {standkit-0.12.1 → standkit-0.12.2}/tests/test_registry_resolver.py +0 -0
- {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.
|
|
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
|
-
| **есть
|
|
65
|
-
| **нет
|
|
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.
|
|
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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: standkit
|
|
3
|
-
Version: 0.12.
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
_log.warning(f"{reason}")
|
|
148
154
|
return False, reason
|
|
149
155
|
|
|
150
|
-
|
|
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} — перехват отменён"
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
f"
|
|
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"
|
|
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
|
-
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}"
|
|
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
|
-
|
|
609
|
-
"
|
|
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-клиента И ни одного
|
|
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")
|