standkit 0.3.7__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.
standkit/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ standkit — свободное (MIT) ядро управления жизненным циклом стендов BPMSoft.
3
+
4
+ Пакет не содержит лицензионно-чувствительного контента платформы BPMSoft и не
5
+ зависит от сторонних библиотек (stdlib-only). Полная документация — см. README.md
6
+ и docs/ARCHITECTURE.md в корне репозитория.
7
+ """
8
+
9
+ __version__ = "0.3.7"
10
+
11
+ __all__ = ["__version__"]
standkit/health.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ Health-пробы стенда: жив ли процесс, отвечает ли HTTP, открыт ли TCP-порт
3
+ (используется как поверхностная проверка живости БД/Redis).
4
+
5
+ Все пробы — быстрые и не требуют сторонних зависимостей (только stdlib:
6
+ ``socket``, ``urllib``). Глубокие проверки (реальный SQL-запрос к БД, PING к
7
+ Redis по протоколу) — сознательно вынесены в TODO под опциональный флаг,
8
+ чтобы базовый health-чек оставался лёгким и не тянул psycopg2/pyodbc/redis-py
9
+ в обязательные зависимости ядра.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import socket
15
+ import urllib.error
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from standkit.models import ProbeState, Stand, StandStatus
21
+
22
+
23
+ def process_alive(pidfile: Path) -> bool:
24
+ """
25
+ Проверяет, жив ли процесс, чей pid записан в ``pidfile``.
26
+
27
+ Если файла нет или он не читается — считается, что процесс не запущен.
28
+ Импортирует standkit.platform лениво, чтобы health.py можно было
29
+ использовать и для проверки "чужих" процессов без завязки на lifecycle.
30
+ """
31
+ from standkit import platform as _platform # локальный импорт — избегаем цикла
32
+
33
+ if not pidfile.exists():
34
+ return False
35
+ try:
36
+ pid = int(pidfile.read_text(encoding="utf-8").strip())
37
+ except (ValueError, OSError):
38
+ return False
39
+ return _platform.is_alive(pid)
40
+
41
+
42
+ def process_running(
43
+ pidfile: Optional[Path],
44
+ host: Optional[str] = None,
45
+ port: Optional[int] = None,
46
+ ) -> bool:
47
+ """
48
+ Считает процесс стенда "живым", если ЛИБО жив pidfile standkit (стенд
49
+ поднят самим ядром), ЛИБО слушается TCP-порт стенда (стенд поднят извне
50
+ standkit — вручную, через IIS/systemd/сторонний скрипт и т.п.).
51
+
52
+ Это расширение process_alive: тот проверяет только pidfile, этот —
53
+ комбинирует обе приметы живости, потому что реальные стенды часто
54
+ поднимаются не через lifecycle.start().
55
+ """
56
+ if pidfile is not None and process_alive(pidfile):
57
+ return True
58
+ if host and port:
59
+ return tcp_open(host, port)
60
+ return False
61
+
62
+
63
+ def http_ok(url: str, *, timeout: float = 3.0) -> bool:
64
+ """
65
+ Проверяет, отвечает ли HTTP(S)-эндпоинт (любой код ответа < 500 считается
66
+ "живым" — стенд может честно отдавать 401/403 до логина, это не авария).
67
+
68
+ Сетевые ошибки (отказано в соединении, DNS, таймаут) → False, без исключений
69
+ наружу — это намеренно проба, а не операция, которая должна падать.
70
+ """
71
+ try:
72
+ req = urllib.request.Request(url, method="GET")
73
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
74
+ return resp.status < 500
75
+ except urllib.error.HTTPError as exc:
76
+ # Сервер ответил (пусть и ошибкой) — значит, живой.
77
+ return exc.code < 500
78
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError):
79
+ return False
80
+
81
+
82
+ def tcp_open(host: str, port: int, *, timeout: float = 2.0) -> bool:
83
+ """
84
+ Проверяет, открыт ли TCP-порт (используется как поверхностная liveness-проба
85
+ БД/Redis — не подменяет полноценный запрос к сервису).
86
+ """
87
+ if not host or not port:
88
+ return False
89
+ try:
90
+ with socket.create_connection((host, port), timeout=timeout):
91
+ return True
92
+ except (OSError, ValueError):
93
+ return False
94
+
95
+
96
+ def db_deep_check(stand: Stand) -> ProbeState:
97
+ """
98
+ TODO(следующая итерация): полноценная проверка БД (реальный SELECT 1 через
99
+ psycopg2 для postgres / pyodbc для mssql). Требует опциональных
100
+ зависимостей, которые НЕ должны стать обязательными для ядра — включать
101
+ только по явному флагу вызывающей стороны.
102
+
103
+ Пока — заглушка, всегда возвращающая SKIPPED, чтобы вызывающий код мог
104
+ отличить "проверка не выполнялась" от "проверка провалилась".
105
+ """
106
+ return ProbeState.SKIPPED
107
+
108
+
109
+ def redis_deep_check(stand: Stand) -> ProbeState:
110
+ """TODO(следующая итерация): полноценный PING к Redis (redis-py, опциональная зависимость)."""
111
+ return ProbeState.SKIPPED
112
+
113
+
114
+ def check_stand(
115
+ stand: Stand,
116
+ *,
117
+ pidfile: Optional[Path] = None,
118
+ http_path: str = "/",
119
+ deep_db: bool = False,
120
+ deep_redis: bool = False,
121
+ ) -> StandStatus:
122
+ """
123
+ Собирает сводный StandStatus по всем доступным быстрым пробам.
124
+
125
+ ``pidfile`` — если не передан, процесс-проба пропускается (UNKNOWN) —
126
+ вызывающая сторона (lifecycle) знает свой путь к pidfile лучше, чем этот
127
+ модуль по умолчанию.
128
+ """
129
+ status = StandStatus(name=stand.name)
130
+
131
+ if pidfile is not None or (stand.stand_host and stand.stand_port):
132
+ is_up = process_running(pidfile, stand.stand_host, stand.stand_port)
133
+ status.process = ProbeState.OK if is_up else ProbeState.DOWN
134
+ else:
135
+ status.process = ProbeState.UNKNOWN
136
+
137
+ if stand.stand_host and stand.stand_port:
138
+ url = f"http://{stand.stand_host}:{stand.stand_port}{http_path}"
139
+ status.http = ProbeState.OK if http_ok(url) else ProbeState.DOWN
140
+ else:
141
+ status.http = ProbeState.UNKNOWN
142
+
143
+ if stand.db_host and stand.db_port:
144
+ status.db = ProbeState.OK if tcp_open(stand.db_host, stand.db_port) else ProbeState.DOWN
145
+ if deep_db:
146
+ status.db = db_deep_check(stand)
147
+ else:
148
+ status.db = ProbeState.UNKNOWN
149
+
150
+ redis_host = stand.extra.get("redis_host")
151
+ redis_port = stand.extra.get("redis_port")
152
+ if redis_host and redis_port:
153
+ status.redis = ProbeState.OK if tcp_open(redis_host, int(redis_port)) else ProbeState.DOWN
154
+ if deep_redis:
155
+ status.redis = redis_deep_check(stand)
156
+ else:
157
+ status.redis = ProbeState.UNKNOWN
158
+
159
+ # TODO: last_deploy — задел на будущее, источник данных пока не определён
160
+ # (кандидат — метаданные из BPMkit deploy_status/deploy_verify).
161
+ status.last_deploy = ProbeState.UNKNOWN
162
+
163
+ return status
standkit/lifecycle.py ADDED
@@ -0,0 +1,191 @@
1
+ """
2
+ Жизненный цикл стенда: start/stop/restart поверх standkit.platform.
3
+
4
+ Хранит pid запущенного стенда в per-stand pidfile в рабочей папке (по умолчанию
5
+ ``<домашняя папка пользователя>/.standkit/run/<имя стенда>.pid``), чтобы между
6
+ разными вызовами (в т.ч. из другого процесса Python — например, из
7
+ standkit_agent) можно было понять, жив ли стенд и как его остановить.
8
+
9
+ Работает только для ``transport == "local"``. Для ``transport == "agent"``
10
+ вызывающая сторона (GUI/клиент) должна ходить по HTTP к соответствующему
11
+ standkit_agent — этот модуль намеренно не содержит сетевого кода (см.
12
+ standkit_gui.client).
13
+
14
+ TODO(следующая итерация):
15
+ - polling готовности после start() (сейчас просто возвращает pid, готовность
16
+ веб-хоста нужно проверять отдельно через standkit.health.http_ok в цикле);
17
+ - блокировка pidfile от гонки двух одновременных start() одного стенда;
18
+ - восстановление после "потерянного" pidfile (процесс с этим pid уже другой —
19
+ нужна доп. проверка, например по имени командной строки).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import shutil
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ from standkit import platform as _platform
30
+ from standkit.models import Stand, Transport
31
+
32
+ _DEFAULT_RUN_DIR = Path.home() / ".standkit" / "run"
33
+ _DEFAULT_LOG_DIR = Path.home() / ".standkit" / "logs"
34
+
35
+ # Пауза после spawn_hidden() перед проверкой "жив ли процесс" — типичный
36
+ # симптом "тихого" провала старта: неверный dll/аргументы, .NET-хост пишет
37
+ # ошибку в лог и завершается за доли секунды, а spawn_hidden() успевает
38
+ # вернуть валидный pid до этого момента. Вынесено в параметр start(), чтобы
39
+ # тесты могли передать 0 и не ждать реальное время.
40
+ _DEFAULT_STARTUP_CHECK_DELAY = 0.6
41
+
42
+
43
+ class LifecycleError(Exception):
44
+ """Ошибки управления жизненным циклом стенда."""
45
+
46
+
47
+ def pidfile_path(stand: Stand, run_dir: Optional[Path] = None) -> Path:
48
+ """Путь к pid-файлу стенда (по умолчанию — общая рабочая папка standkit пользователя)."""
49
+ run_dir = Path(run_dir) if run_dir else _DEFAULT_RUN_DIR
50
+ return run_dir / f"{stand.name}.pid"
51
+
52
+
53
+ def log_path(stand: Stand, log_dir: Optional[Path] = None) -> Path:
54
+ """Путь к лог-файлу стенда (см. standkit.logs.tail)."""
55
+ log_dir = Path(log_dir) if log_dir else _DEFAULT_LOG_DIR
56
+ return log_dir / f"{stand.name}.log"
57
+
58
+
59
+ def _read_pid(pf: Path) -> Optional[int]:
60
+ if not pf.exists():
61
+ return None
62
+ try:
63
+ return int(pf.read_text(encoding="utf-8").strip())
64
+ except (ValueError, OSError):
65
+ return None
66
+
67
+
68
+ def _write_pid(pf: Path, pid: int) -> None:
69
+ pf.parent.mkdir(parents=True, exist_ok=True)
70
+ pf.write_text(str(pid), encoding="utf-8")
71
+
72
+
73
+ def _require_local(stand: Stand) -> None:
74
+ if stand.transport != Transport.LOCAL:
75
+ raise LifecycleError(
76
+ f"Стенд '{stand.name}' имеет transport={stand.transport.value!r} — "
77
+ "управление headless-процессом доступно только для transport='local' "
78
+ "(для 'agent' используйте HTTP-клиент standkit_gui.client к соответствующему агенту)"
79
+ )
80
+
81
+
82
+ def _resolve_dotnet(dotnet: str) -> str:
83
+ """
84
+ Резолвит команду ``dotnet`` записи стенда в конкретный исполняемый путь.
85
+
86
+ - Если ``dotnet`` уже указывает на существующий файл (абсолютный или
87
+ относительный путь) — используется как есть, PATH не требуется.
88
+ - Иначе (голое имя вроде ``"dotnet"``, дефолт по схеме ``Stand``) ищется
89
+ в PATH через ``shutil.which``.
90
+
91
+ Раньше отсутствие ``dotnet`` в PATH приводило к "тихому" провалу —
92
+ subprocess.Popen падал где-то внутри spawn_hidden() с малопонятной
93
+ OSError, либо (на некоторых системах) вообще не поднимал процесс без
94
+ видимой ошибки в UI хаба. Явная проверка здесь даёт понятный текст ДО
95
+ попытки спавна.
96
+ """
97
+ candidate = Path(dotnet)
98
+ if candidate.exists():
99
+ return str(candidate)
100
+ resolved = shutil.which(dotnet)
101
+ if resolved is None:
102
+ raise LifecycleError(
103
+ f"dotnet не найден в PATH: {dotnet!r} — установите .NET SDK/Runtime "
104
+ "или укажите полный путь в поле 'dotnet' записи реестра стенда"
105
+ )
106
+ return resolved
107
+
108
+
109
+ def start(
110
+ stand: Stand,
111
+ *,
112
+ run_dir: Optional[Path] = None,
113
+ log_dir: Optional[Path] = None,
114
+ startup_check_delay: float = _DEFAULT_STARTUP_CHECK_DELAY,
115
+ ) -> int:
116
+ """
117
+ Запускает стенд headless-процессом, если он ещё не запущен.
118
+
119
+ Возвращает pid процесса (существующего, если стенд уже был жив, либо
120
+ только что созданного). Бросает ``LifecycleError`` с понятным текстом,
121
+ если ``dotnet`` не резолвится (см. ``_resolve_dotnet``) либо процесс
122
+ завершился сразу после старта (см. проверку ``is_alive`` ниже) — раньше
123
+ в обоих случаях start() мог тихо "вернуть успех", хотя стенд не поднялся.
124
+
125
+ Команда запуска — ``dotnet <stand_dll>`` в ``stand_dir``, БЕЗ аргументов
126
+ командной строки: BPMSoft.WebHost их не принимает (свой CommandLineParser),
127
+ адрес/порт берётся из конфига стенда. Доп. окружение (ASPNETCORE_ENVIRONMENT
128
+ и т.п.) при необходимости — зона расширения следующей итерации.
129
+ """
130
+ _require_local(stand)
131
+
132
+ pf = pidfile_path(stand, run_dir)
133
+ existing = _read_pid(pf)
134
+ if existing and _platform.is_alive(existing):
135
+ return existing
136
+
137
+ stand_dir = Path(stand.stand_dir)
138
+ if not stand_dir.exists():
139
+ raise LifecycleError(f"Каталог стенда не найден: {stand_dir}")
140
+
141
+ dotnet_path = _resolve_dotnet(stand.dotnet)
142
+ # BPMSoft.WebHost парсит аргументы СВОИМ CommandLineParser и НЕ понимает
143
+ # ASP.NET-флаги вроде --urls (ошибка «Verb '--urls' is not recognized»).
144
+ # Адрес/порт стенд берёт из собственного конфига (appsettings). Поэтому
145
+ # запускаем без доп. аргументов — просто dotnet <stand_dll> в каталоге
146
+ # стенда (так же, как это делает сам BPMkit/кит).
147
+ cmd = [dotnet_path, stand.stand_dll]
148
+ lp = log_path(stand, log_dir)
149
+ pid = _platform.spawn_hidden(cmd, cwd=stand_dir, log_path=lp)
150
+
151
+ if startup_check_delay > 0:
152
+ time.sleep(startup_check_delay)
153
+ if not _platform.is_alive(pid):
154
+ raise LifecycleError(
155
+ f"процесс стенда '{stand.name}' завершился сразу после старта — "
156
+ f"смотрите логи ({lp})"
157
+ )
158
+
159
+ _write_pid(pf, pid)
160
+ return pid
161
+
162
+
163
+ def stop(stand: Stand, *, run_dir: Optional[Path] = None) -> bool:
164
+ """Останавливает стенд, если он запущен. Возвращает True при успешной остановке."""
165
+ _require_local(stand)
166
+
167
+ pf = pidfile_path(stand, run_dir)
168
+ pid = _read_pid(pf)
169
+ if pid is None:
170
+ return True
171
+
172
+ stopped = _platform.stop(pid)
173
+ if stopped:
174
+ try:
175
+ pf.unlink(missing_ok=True)
176
+ except OSError:
177
+ pass
178
+ return stopped
179
+
180
+
181
+ def restart(stand: Stand, *, run_dir: Optional[Path] = None, log_dir: Optional[Path] = None) -> int:
182
+ """Останавливает (если жив) и заново запускает стенд. Возвращает новый pid."""
183
+ stop(stand, run_dir=run_dir)
184
+ return start(stand, run_dir=run_dir, log_dir=log_dir)
185
+
186
+
187
+ def is_running(stand: Stand, *, run_dir: Optional[Path] = None) -> bool:
188
+ """Быстрая проверка «жив ли стенд» по pidfile (обёртка над standkit.health.process_alive)."""
189
+ _require_local(stand)
190
+ pid = _read_pid(pidfile_path(stand, run_dir))
191
+ return pid is not None and _platform.is_alive(pid)
standkit/logs.py ADDED
@@ -0,0 +1,221 @@
1
+ """
2
+ Работа с per-stand лог-файлом: tail последних N строк и генератор "follow"
3
+ (аналог ``tail -f``) для стрим-панели GUI/агента.
4
+
5
+ Читает файл КАК БАЙТЫ и декодирует "умным" перебором кодировок (см.
6
+ ``_decode_bytes``), а не строго как UTF-8: дочерний .NET-процесс стенда на
7
+ Windows нередко пишет консольный вывод в системной однобайтовой кодировке
8
+ (cp1251/cp866), а не в UTF-8 — строгий "utf-8"-декод в этом случае бил
9
+ кириллицу в "◇" вместо того, чтобы показать её как есть.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Iterator, Optional
17
+
18
+ # Однобайтовые кандидаты для скоринга (см. ``_decode_bytes``). UTF-8/UTF-8-BOM
19
+ # сюда не входят — они проверяются отдельно, СТРОГО, до скоринга (см. ниже).
20
+ _SINGLE_BYTE_CANDIDATES = ("cp1251", "cp866")
21
+
22
+ # --- скоринг "читаемости" декодированного текста ---
23
+ #
24
+ # cp1251 и cp866 — обе однобайтовые кодировки: почти ЛЮБАЯ байтовая
25
+ # последовательность декодируется ими БЕЗ ОШИБОК (за редкими исключениями
26
+ # вроде байта 0x98 в cp1251), поэтому раньше "первая, что декодировалась без
27
+ # ошибок" почти всегда отдавала предпочтение cp1251 (она была в списке
28
+ # кандидатов раньше cp866) — даже когда файл был на самом деле в cp866, что
29
+ # давало мойибаке на кириллице. Вместо "первая успешная" — декодируем ОБЕИМИ
30
+ # кандидатными кодировками (``errors="replace"``, чтобы не падать) и выбираем
31
+ # ту, чей результат "читаемее" по эвристической оценке:
32
+ # + печатные ASCII, \r\n\t, кириллица (U+0400-04FF), типичная пунктуация
33
+ # (кавычки-ёлочки, тире, обычные знаки препинания) — увеличивают оценку;
34
+ # - символ-заменитель (U+FFFD, недекодируемый байт), control-символы C1
35
+ # (U+0080-009F), псевдографика box-drawing (U+2500-257F) и прочие редкие
36
+ # latin-1-символы (U+00A0-00FF вне кириллицы) — уменьшают оценку (это
37
+ # типичный "мусор", который получается при декоде байт НЕ той однобайтовой
38
+ # кодировкой).
39
+ _GOOD_PUNCTUATION = set("«»—-.,!?;:()[]{}'\"/\\%$#@&*+=_~`")
40
+
41
+
42
+ def _readability_score(text: str) -> int:
43
+ """Эвристическая оценка "читаемости" текста — см. пояснение выше модуля."""
44
+ score = 0
45
+ for ch in text:
46
+ cp = ord(ch)
47
+ if ch in "\r\n\t":
48
+ score += 1
49
+ elif 0x20 <= cp <= 0x7E:
50
+ score += 1
51
+ elif 0x0400 <= cp <= 0x04FF:
52
+ score += 2
53
+ elif ch in _GOOD_PUNCTUATION:
54
+ score += 1
55
+ elif ch == "�":
56
+ score -= 6
57
+ elif 0x80 <= cp <= 0x9F:
58
+ score -= 6
59
+ elif 0x2500 <= cp <= 0x257F:
60
+ score -= 5
61
+ elif 0xA0 <= cp <= 0xFF:
62
+ score -= 2
63
+ else:
64
+ score -= 1
65
+ return score
66
+
67
+
68
+ def _decode_single_byte_best(data: bytes) -> str:
69
+ """
70
+ Декодирует байты каждой однобайтовой кандидатной кодировкой (см.
71
+ ``_SINGLE_BYTE_CANDIDATES``, ``errors="replace"`` — эти кодировки
72
+ практически никогда не бросают исключение сами по себе) и возвращает
73
+ результат кодировки с НАИБОЛЬШЕЙ оценкой читаемости (``_readability_score``).
74
+ При равной оценке побеждает первый кандидат по порядку списка (детерминизм).
75
+ """
76
+ best_text: Optional[str] = None
77
+ best_score: Optional[int] = None
78
+ for encoding in _SINGLE_BYTE_CANDIDATES:
79
+ decoded = data.decode(encoding, errors="replace")
80
+ sc = _readability_score(decoded)
81
+ if best_score is None or sc > best_score:
82
+ best_text, best_score = decoded, sc
83
+ assert best_text is not None # список кандидатов непуст
84
+ return best_text
85
+
86
+
87
+ def _decode_bytes(data: bytes) -> str:
88
+ """
89
+ Декодирует байты лога:
90
+
91
+ (a) если байты — валидный UTF-8/UTF-8 с BOM (``errors="strict"``) —
92
+ возвращает его как есть (UTF-8 в приоритете, если он строго валиден:
93
+ однобайтовые кодировки почти никогда не бракуют произвольные байты,
94
+ поэтому им нельзя доверять раньше строгой UTF-8-проверки);
95
+ (b) иначе — скоринг читаемости между cp1251 и cp866 (см.
96
+ ``_decode_single_byte_best``), кодировка с большей оценкой побеждает;
97
+ (c) если это тоже не помогло (не должно происходить — (b) всегда
98
+ возвращает строку) — фолбэк на UTF-8 с ``errors="replace"``, чтобы
99
+ никогда не падать на бинарном мусоре.
100
+ """
101
+ for encoding in ("utf-8-sig", "utf-8"):
102
+ try:
103
+ return data.decode(encoding, errors="strict")
104
+ except UnicodeDecodeError:
105
+ continue
106
+ try:
107
+ return _decode_single_byte_best(data)
108
+ except Exception:
109
+ return data.decode("utf-8", errors="replace")
110
+
111
+
112
+ def tail(log_path: Path, n: int = 100) -> list[str]:
113
+ """
114
+ Возвращает последние ``n`` строк файла лога (без завершающих переводов строк).
115
+
116
+ Если файла ещё нет (стенд ни разу не запускался) — возвращает пустой список,
117
+ а не бросает исключение: отсутствие лога — обычное состояние свежей записи
118
+ реестра.
119
+
120
+ Реализация читает файл целиком (байтами, с "умным" декодом — см.
121
+ ``_decode_bytes``) — для типичных per-stand логов (десятки МБ) этого
122
+ достаточно; TODO(следующая итерация): постраничное чтение с конца файла
123
+ для очень больших логов (сотни МБ и больше), чтобы не держать в памяти
124
+ файл целиком.
125
+ """
126
+ p = Path(log_path)
127
+ if not p.exists():
128
+ return []
129
+ data = p.read_bytes()
130
+ text = _decode_bytes(data)
131
+ lines = text.splitlines()
132
+ return lines[-n:] if n > 0 else []
133
+
134
+
135
+ _SESSION_START_MARKER = "=== START pid="
136
+ _APP_STARTING_MARKER = "Application starting"
137
+
138
+
139
+ def extract_current_session(text: str) -> str:
140
+ """
141
+ Обрезает текст лога до ТЕКУЩЕЙ (последней) сессии стенда — панель
142
+ "Текущее состояние" в хабе не должна показывать хвост со ВСЕМИ прошлыми
143
+ запусками (десятки блоков "=== START pid=…"/"Application starting…"), а
144
+ только вывод с начала последнего запуска.
145
+
146
+ Границы сессии ищутся с КОНЦА текста (побеждает последнее вхождение), в
147
+ порядке приоритета:
148
+ 1. строка, содержащая ``"=== START pid="`` — маркер, который standkit
149
+ пишет в лог при старте процесса стенда (формат
150
+ ``=== START pid=NNNN ts=<ISO> ===``);
151
+ 2. если такой строки нет — строка, содержащая ``"Application starting"``
152
+ (типичная первая строка вывода .NET-хоста стенда при холодном
153
+ старте — используется как fallback для логов, которые ведёт не сам
154
+ standkit, а сам стенд/сторонний раннер, без маркера ``START pid=``).
155
+
156
+ Если ни одна граница не найдена — возвращает текст без изменений (лог
157
+ короткий/не содержит распознаваемых маркеров, обрезать нечего).
158
+
159
+ Найденная граничная строка ВКЛЮЧАЕТСЯ в результат (сессия показывается
160
+ с "=== START pid=…" или "Application starting…" включительно).
161
+ """
162
+ if not text:
163
+ return text
164
+
165
+ lines = text.split("\n")
166
+
167
+ start_idx: Optional[int] = None
168
+ for i in range(len(lines) - 1, -1, -1):
169
+ if _SESSION_START_MARKER in lines[i]:
170
+ start_idx = i
171
+ break
172
+
173
+ if start_idx is None:
174
+ for i in range(len(lines) - 1, -1, -1):
175
+ if _APP_STARTING_MARKER in lines[i]:
176
+ start_idx = i
177
+ break
178
+
179
+ if start_idx is None:
180
+ return text
181
+
182
+ return "\n".join(lines[start_idx:])
183
+
184
+
185
+ def follow(log_path: Path, *, poll_interval: float = 0.5) -> Iterator[str]:
186
+ """
187
+ Генератор, отдающий новые строки лога по мере их появления (аналог ``tail -f``).
188
+
189
+ Блокирующий: между появлениями новых строк "спит" ``poll_interval`` секунд.
190
+ Предназначен для использования в отдельном потоке/соединении (например,
191
+ long-poll или SSE-эндпоинт агента) — вызывающая сторона сама решает, когда
192
+ остановить итерацию (просто перестать тянуть значения из генератора).
193
+
194
+ Читает файл в байтовом режиме и декодирует каждую завершённую строку через
195
+ ``_decode_bytes`` — тот же "умный" перебор кодировок, что и в ``tail``,
196
+ построчно (буферизуя неполный "хвост" до следующего перевода строки),
197
+ чтобы не резать многобайтовые последовательности пополам между двумя
198
+ опросами.
199
+
200
+ TODO(следующая итерация): обработка ротации лог-файла (например, через
201
+ logrotate) — сейчас при пересоздании файла с тем же именем позиция чтения
202
+ может "уехать" за пределы нового файла и follow() перестанет отдавать строки
203
+ до следующего перезапуска процесса-читателя.
204
+ """
205
+ p = Path(log_path)
206
+ # Ждём появления файла, если стенд ещё не успел его создать.
207
+ while not p.exists():
208
+ time.sleep(poll_interval)
209
+
210
+ with p.open("rb") as f:
211
+ f.seek(0, 2) # сразу к концу файла — follow не показывает историю
212
+ buffer = b""
213
+ while True:
214
+ chunk = f.read()
215
+ if chunk:
216
+ buffer += chunk
217
+ while b"\n" in buffer:
218
+ raw_line, buffer = buffer.split(b"\n", 1)
219
+ yield _decode_bytes(raw_line).rstrip("\r")
220
+ else:
221
+ time.sleep(poll_interval)