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/models.py ADDED
@@ -0,0 +1,211 @@
1
+ """
2
+ Модели данных ядра standkit: описание стенда (Stand) и его состояния (StandStatus).
3
+
4
+ Поля Stand буквально повторяют схему записи в реестре projects.json (см.
5
+ projects.sample.json в корне репозитория) плюс универсальное поле транспорта
6
+ ``transport``, которое определяет, как ядро должно управлять стендом:
7
+
8
+ - "local" — стенд поднимается локально текущим процессом standkit (subprocess,
9
+ см. standkit.platform / standkit.lifecycle);
10
+ - "agent" — стенд управляется через удалённый standkit_agent по HTTP
11
+ (используются agent_url / agent_secret_ref);
12
+ - "ssh" / "winrm" — зарезервировано под будущие транспорты, СХЕМОЙ допускается,
13
+ логика НЕ реализована (см. TODO в lifecycle.py).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field, fields
19
+ from enum import Enum
20
+ from typing import Any, Optional
21
+
22
+
23
+ class Transport(str, Enum):
24
+ """Способ, которым ядро/GUI дотягивается до стенда."""
25
+
26
+ LOCAL = "local"
27
+ AGENT = "agent"
28
+ # Задел на будущее — схема допускает значения, реализации пока нет.
29
+ SSH = "ssh"
30
+ WINRM = "winrm"
31
+
32
+
33
+ class ProbeState(str, Enum):
34
+ """Единое множество состояний для любой health-пробы."""
35
+
36
+ OK = "ok"
37
+ DOWN = "down"
38
+ UNKNOWN = "unknown"
39
+ # Проба сознательно не выполнялась (например, глубокая БД-проба выключена флагом).
40
+ SKIPPED = "skipped"
41
+
42
+
43
+ # Поля, обязательные для валидной записи Stand (без них смысла в объекте нет).
44
+ _REQUIRED_FIELDS = ("name", "stand_dir")
45
+
46
+
47
+ @dataclass
48
+ class Stand:
49
+ """
50
+ Описание одного стенда BPMSoft — один к одному со схемой записи в projects.json.
51
+
52
+ ``name`` — ключ записи в реестре (не хранится внутри самой записи в JSON,
53
+ проставляется registry.py при чтении).
54
+ """
55
+
56
+ name: str
57
+
58
+ # --- Транспорт управления стендом ---
59
+ transport: Transport = Transport.LOCAL
60
+ agent_url: Optional[str] = None
61
+ agent_secret_ref: Optional[str] = None
62
+
63
+ # --- Процесс стенда ---
64
+ stand_dir: str = ""
65
+ stand_dll: str = "BPMSoft.WebHost.dll"
66
+ dotnet: str = "dotnet"
67
+ stand_host: str = "127.0.0.1"
68
+ stand_port: int = 5000
69
+
70
+ # --- База данных ---
71
+ db_type: str = "postgres"
72
+ db_host: str = ""
73
+ db_port: int = 0
74
+ db_name: str = ""
75
+ db_user: str = ""
76
+ db_password: str = ""
77
+ secret_ref_db: Optional[str] = None
78
+
79
+ # --- Администратор стенда ---
80
+ admin_user: str = "Supervisor"
81
+ secret_ref_admin: Optional[str] = None
82
+
83
+ # --- Прочее ---
84
+ distrib_dir: str = ""
85
+ description: str = ""
86
+ customer: str = ""
87
+
88
+ # Произвольные дополнительные поля из реестра, которые ядро не знает явно,
89
+ # но не хочет терять при повторной записи (forward-compatibility).
90
+ extra: dict = field(default_factory=dict)
91
+
92
+ @classmethod
93
+ def from_dict(cls, name: str, data: dict) -> "Stand":
94
+ """
95
+ Строит Stand из словаря записи реестра (как он приходит из JSON).
96
+
97
+ Неизвестные поля не теряются — уходят в ``extra``, чтобы round-trip
98
+ чтение→запись не терял данные, добавленные другими инструментами
99
+ экосистемы (например, provision_stand из BPMkit).
100
+ """
101
+ known = {f.name for f in fields(cls)} - {"name", "extra"}
102
+ kwargs: dict[str, Any] = {}
103
+ extra: dict[str, Any] = {}
104
+ for key, value in data.items():
105
+ if key == "transport":
106
+ kwargs["transport"] = _coerce_transport(value)
107
+ elif key in known:
108
+ kwargs[key] = value
109
+ elif key == "name":
110
+ continue
111
+ else:
112
+ extra[key] = value
113
+ return cls(name=name, extra=extra, **kwargs)
114
+
115
+ def to_dict(self) -> dict:
116
+ """Сериализует запись обратно в словарь для записи в projects.json (без ``name``)."""
117
+ result: dict[str, Any] = {}
118
+ for f in fields(self):
119
+ if f.name in ("name", "extra"):
120
+ continue
121
+ value = getattr(self, f.name)
122
+ if f.name == "transport":
123
+ value = value.value if isinstance(value, Transport) else value
124
+ result[f.name] = value
125
+ result.update(self.extra)
126
+ return result
127
+
128
+ def validate(self) -> list[str]:
129
+ """
130
+ Минимальная валидация записи. Возвращает список текстов ошибок
131
+ (пустой список — запись валидна). Не бросает исключений намеренно —
132
+ вызывающий код сам решает, насколько строго реагировать.
133
+ """
134
+ errors: list[str] = []
135
+ if not self.name:
136
+ errors.append("name не может быть пустым")
137
+ if not self.stand_dir:
138
+ errors.append("stand_dir не может быть пустым")
139
+ if self.transport == Transport.AGENT:
140
+ if not self.agent_url:
141
+ errors.append("transport=agent требует agent_url")
142
+ return errors
143
+
144
+
145
+ def _coerce_transport(value: Any) -> Transport:
146
+ if isinstance(value, Transport):
147
+ return value
148
+ try:
149
+ return Transport(str(value))
150
+ except ValueError:
151
+ # Неизвестное будущее значение транспорта — не роняем чтение реестра,
152
+ # оставляем как UNKNOWN-эквивалент через LOCAL с пометкой в extra.
153
+ return Transport.LOCAL
154
+
155
+
156
+ @dataclass
157
+ class StandStatus:
158
+ """
159
+ Снимок состояния стенда по всем доступным пробам разом.
160
+
161
+ Каждое поле — состояние независимой пробы (см. standkit.health):
162
+ - process: жив ли процесс стенда (по pid-файлу);
163
+ - http: отвечает ли web-хост стенда по HTTP;
164
+ - db: открыт ли TCP-порт БД (не полноценный запрос — TODO);
165
+ - redis: открыт ли TCP-порт Redis (если используется);
166
+ - last_deploy: состояние последнего деплоя — вне ядра здоровья, задел на
167
+ будущее для витрины GUI (TODO: источник данных).
168
+ """
169
+
170
+ name: str
171
+ process: ProbeState = ProbeState.UNKNOWN
172
+ http: ProbeState = ProbeState.UNKNOWN
173
+ db: ProbeState = ProbeState.UNKNOWN
174
+ redis: ProbeState = ProbeState.UNKNOWN
175
+ last_deploy: ProbeState = ProbeState.UNKNOWN
176
+ details: dict = field(default_factory=dict)
177
+
178
+ @classmethod
179
+ def from_dict(cls, data: dict) -> "StandStatus":
180
+ """Строит StandStatus из словаря (например, ответа агента по HTTP)."""
181
+ kwargs: dict[str, Any] = {"name": data.get("name", "")}
182
+ for key in ("process", "http", "db", "redis", "last_deploy"):
183
+ if key in data:
184
+ kwargs[key] = _coerce_probe_state(data[key])
185
+ kwargs["details"] = dict(data.get("details", {}))
186
+ return cls(**kwargs)
187
+
188
+ def to_dict(self) -> dict:
189
+ return {
190
+ "name": self.name,
191
+ "process": self.process.value,
192
+ "http": self.http.value,
193
+ "db": self.db.value,
194
+ "redis": self.redis.value,
195
+ "last_deploy": self.last_deploy.value,
196
+ "details": self.details,
197
+ }
198
+
199
+ @property
200
+ def is_healthy(self) -> bool:
201
+ """Грубая сводная оценка: процесс и HTTP в порядке (БД/Redis — опциональны)."""
202
+ return self.process == ProbeState.OK and self.http in (ProbeState.OK, ProbeState.SKIPPED)
203
+
204
+
205
+ def _coerce_probe_state(value: Any) -> ProbeState:
206
+ if isinstance(value, ProbeState):
207
+ return value
208
+ try:
209
+ return ProbeState(str(value))
210
+ except ValueError:
211
+ return ProbeState.UNKNOWN
standkit/platform.py ADDED
@@ -0,0 +1,174 @@
1
+ """
2
+ OS-абстракция запуска процессов: скрытый (headless, без консольного окна)
3
+ процесс на Windows, отсоединённый (setsid) процесс на Linux.
4
+
5
+ Никакого хардкода путей вида ``C:\\...`` — все пути принимаются и возвращаются
6
+ как ``pathlib.Path``. Модуль не знает ничего про BPMSoft — только про то, как
7
+ корректно поднять/остановить/проверить произвольный процесс кроссплатформенно.
8
+
9
+ TODO(следующая итерация):
10
+ - Windows: сейчас используется CREATE_NO_WINDOW через subprocess без
11
+ полноценной службы (Job Object) — процесс-потомок может пережить
12
+ неожиданное падение родителя без присмотра. Рассмотреть Job Object API
13
+ (через ctypes) для гарантированной остановки дерева процессов.
14
+ - Linux: полноценная демонизация (двойной fork, отвязка от сессии) —
15
+ сейчас используется упрощённый setsid через start_new_session=True,
16
+ этого достаточно для большинства случаев, но не эквивалентно классическому
17
+ double-fork демону.
18
+ - graceful stop (SIGTERM/таймаут/SIGKILL на Linux; CTRL_BREAK_EVENT на
19
+ Windows) — сейчас stop() сразу и безусловно завершает процесс.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import signal
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Optional, Sequence
30
+
31
+
32
+ class ProcessError(Exception):
33
+ """Ошибки запуска/остановки/проверки процесса."""
34
+
35
+
36
+ def spawn_hidden(cmd: Sequence[str], cwd: Path, log_path: Path) -> int:
37
+ """
38
+ Запускает процесс в фоне, без видимого консольного окна (Windows) /
39
+ в новой сессии (Linux), с перенаправлением stdout+stderr в log_path.
40
+
41
+ Возвращает pid запущенного процесса. Не блокирует вызывающий поток.
42
+ """
43
+ cwd = Path(cwd)
44
+ log_path = Path(log_path)
45
+ log_path.parent.mkdir(parents=True, exist_ok=True)
46
+
47
+ # Открываем лог в режиме дозаписи — предыдущие запуски не теряются молча.
48
+ log_file = open(log_path, "ab", buffering=0)
49
+
50
+ popen_kwargs: dict = {
51
+ "cwd": str(cwd),
52
+ "stdout": log_file,
53
+ "stderr": subprocess.STDOUT,
54
+ "stdin": subprocess.DEVNULL,
55
+ }
56
+
57
+ if sys.platform == "win32":
58
+ # Скрытое окно + отдельная группа процессов, чтобы CTRL-C консоли
59
+ # родителя (если он в консоли) не убивал дочерний процесс стенда.
60
+ creationflags = 0
61
+ creationflags |= getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
62
+ creationflags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
63
+ popen_kwargs["creationflags"] = creationflags
64
+ else:
65
+ # Новая сессия — процесс переживает завершение управляющего терминала
66
+ # и получает свою группу для последующего управляемого stop().
67
+ popen_kwargs["start_new_session"] = True
68
+
69
+ try:
70
+ proc = subprocess.Popen(list(cmd), **popen_kwargs)
71
+ except OSError as exc:
72
+ raise ProcessError(f"Не удалось запустить процесс {cmd!r}: {exc}") from exc
73
+ finally:
74
+ log_file.close()
75
+
76
+ return proc.pid
77
+
78
+
79
+ def is_alive(pid: int) -> bool:
80
+ """Проверяет, жив ли процесс с данным pid (кроссплатформенно)."""
81
+ if pid <= 0:
82
+ return False
83
+ if sys.platform == "win32":
84
+ return _is_alive_windows(pid)
85
+ return _is_alive_posix(pid)
86
+
87
+
88
+ def stop(pid: int, *, timeout: float = 10.0) -> bool:
89
+ """
90
+ Останавливает процесс по pid.
91
+
92
+ TODO: сейчас — безусловное завершение (SIGTERM/taskkill), без ожидания
93
+ graceful shutdown и без эскалации до SIGKILL по таймауту. Для веб-хоста
94
+ BPMSoft это может быть недостаточно аккуратно (незавершённые транзакции) —
95
+ в следующей итерации добавить: сигнал → ожидание ``timeout`` секунд с
96
+ опросом is_alive() → принудительное убийство, если процесс не завершился.
97
+
98
+ Возвращает True, если процесс на момент вызова считался остановленным
99
+ (уже не был жив, либо остановлен успешно).
100
+ """
101
+ if not is_alive(pid):
102
+ return True
103
+
104
+ if sys.platform == "win32":
105
+ return _stop_windows(pid)
106
+ return _stop_posix(pid)
107
+
108
+
109
+ # --- Windows-специфика ---
110
+
111
+ def _is_alive_windows(pid: int) -> bool:
112
+ try:
113
+ import ctypes
114
+
115
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
116
+ handle = ctypes.windll.kernel32.OpenProcess( # type: ignore[attr-defined]
117
+ PROCESS_QUERY_LIMITED_INFORMATION, False, pid
118
+ )
119
+ if not handle:
120
+ return False
121
+ try:
122
+ exit_code = ctypes.c_ulong()
123
+ STILL_ACTIVE = 259
124
+ ok = ctypes.windll.kernel32.GetExitCodeProcess( # type: ignore[attr-defined]
125
+ handle, ctypes.byref(exit_code)
126
+ )
127
+ return bool(ok) and exit_code.value == STILL_ACTIVE
128
+ finally:
129
+ ctypes.windll.kernel32.CloseHandle(handle) # type: ignore[attr-defined]
130
+ except Exception:
131
+ # Фолбэк на tasklist, если ctypes-путь недоступен по какой-то причине.
132
+ try:
133
+ out = subprocess.check_output(
134
+ ["tasklist", "/FI", f"PID eq {pid}"], text=True, stderr=subprocess.DEVNULL
135
+ )
136
+ return str(pid) in out
137
+ except Exception:
138
+ return False
139
+
140
+
141
+ def _stop_windows(pid: int) -> bool:
142
+ try:
143
+ subprocess.run(
144
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
145
+ check=False,
146
+ stdout=subprocess.DEVNULL,
147
+ stderr=subprocess.DEVNULL,
148
+ )
149
+ except OSError as exc:
150
+ raise ProcessError(f"Не удалось остановить процесс {pid}: {exc}") from exc
151
+ return not is_alive(pid)
152
+
153
+
154
+ # --- Linux/POSIX-специфика ---
155
+
156
+ def _is_alive_posix(pid: int) -> bool:
157
+ try:
158
+ os.kill(pid, 0)
159
+ return True
160
+ except ProcessLookupError:
161
+ return False
162
+ except PermissionError:
163
+ # Процесс существует, но принадлежит другому пользователю — жив.
164
+ return True
165
+
166
+
167
+ def _stop_posix(pid: int) -> bool:
168
+ try:
169
+ os.kill(pid, signal.SIGTERM)
170
+ except ProcessLookupError:
171
+ return True
172
+ except OSError as exc:
173
+ raise ProcessError(f"Не удалось остановить процесс {pid}: {exc}") from exc
174
+ return not is_alive(pid)
standkit/registry.py ADDED
@@ -0,0 +1,223 @@
1
+ """
2
+ Реестр стендов — чтение/запись projects.json.
3
+
4
+ Формат совпадает со схемой BPMkit (см. projects.sample.json в корне репозитория):
5
+ верхний уровень ``{"default": <имя>, "stands": {<имя>: {...}}}``. Файл читается как
6
+ ``utf-8-sig`` (терпим к BOM, который оставляют некоторые редакторы на Windows) и
7
+ пишется БЕЗ BOM, чтобы не плодить дифф на ровном месте между инструментами.
8
+
9
+ Важно: этот модуль НЕ занимается провижинингом стендов (созданием каталогов,
10
+ БД, установкой дистрибутива) — только чтением/записью записей реестра для уже
11
+ существующих стендов. Провижининг — зона ответственности платного продукта
12
+ (BPMkit: provision_stand и т.п.).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Iterable, Optional
22
+
23
+ from standkit.models import Stand
24
+
25
+ _DEFAULT_SCHEMA_VERSION = 1
26
+
27
+ # Имя папки с общими файлами экосистемы BPMkit (реестр стендов, конфиг GUI и
28
+ # т.п.) — единая точка правды между standkit и BPMkit MCP.
29
+ _BPMKIT_DIR_NAME = "BPMkit"
30
+ _REGISTRY_FILE_NAME = "projects.json"
31
+ _ENV_REGISTRY_VAR = "BPMSOFT_PROJECTS_FILE"
32
+
33
+
34
+ class RegistryError(Exception):
35
+ """Ошибки чтения/записи/валидации реестра стендов."""
36
+
37
+
38
+ def bpmkit_config_dir() -> Path:
39
+ """
40
+ Каталог общих файлов экосистемы BPMkit (реестр стендов, конфиг GUI и т.п.)
41
+ — та же папка, которую резолвит клиентский MCP BPMkit:
42
+
43
+ - Windows: ``%APPDATA%\\BPMkit``;
44
+ - POSIX: ``$XDG_CONFIG_HOME/BPMkit`` либо, если переменная не задана,
45
+ ``~/.config/BPMkit``.
46
+
47
+ Функция ничего не создаёт на диске — только считает путь.
48
+ """
49
+ if sys.platform == "win32":
50
+ appdata = os.environ.get("APPDATA")
51
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
52
+ return base / _BPMKIT_DIR_NAME
53
+
54
+ xdg = os.environ.get("XDG_CONFIG_HOME")
55
+ base = Path(xdg) if xdg else Path.home() / ".config"
56
+ return base / _BPMKIT_DIR_NAME
57
+
58
+
59
+ def default_registry_path() -> Path:
60
+ """
61
+ Резолвит путь к реестру стендов ТОЙ ЖЕ цепочкой, что использует BPMkit MCP,
62
+ чтобы standkit и кит смотрели в один и тот же ``projects.json`` без
63
+ дополнительной настройки:
64
+
65
+ 1. env ``BPMSOFT_PROJECTS_FILE`` — если задана и путь существует, он в
66
+ приоритете;
67
+ 2. канонический путь кита: ``%APPDATA%\\BPMkit\\projects.json`` (Windows)
68
+ или ``$XDG_CONFIG_HOME/BPMkit/projects.json`` / ``~/.config/BPMkit/
69
+ projects.json`` (POSIX);
70
+ 3. фолбэк ``./projects.json`` в текущей рабочей директории — для
71
+ standalone-запуска standkit без установленного кита.
72
+
73
+ Возвращает первый СУЩЕСТВУЮЩИЙ файл из цепочки; если ни один не
74
+ существует — возвращает канонический путь п.2 (куда реестр следовало бы
75
+ положить), чтобы вызывающий код мог использовать его как путь для
76
+ первого создания реестра.
77
+ """
78
+ env_value = os.environ.get(_ENV_REGISTRY_VAR)
79
+ if env_value:
80
+ env_path = Path(env_value)
81
+ if env_path.exists():
82
+ return env_path
83
+
84
+ canonical = bpmkit_config_dir() / _REGISTRY_FILE_NAME
85
+ if canonical.exists():
86
+ return canonical
87
+
88
+ fallback = Path.cwd() / _REGISTRY_FILE_NAME
89
+ if fallback.exists():
90
+ return fallback
91
+
92
+ return canonical
93
+
94
+
95
+ class Registry:
96
+ """
97
+ Реестр стендов поверх JSON-файла.
98
+
99
+ Пример:
100
+ reg = Registry.load("projects.json")
101
+ stand = reg.get("my-stand")
102
+ reg.add_existing(Stand(name="another", stand_dir="/opt/bpmsoft/another"))
103
+ reg.save()
104
+ """
105
+
106
+ def __init__(self, path: Path, default: str = "", stands: Optional[dict[str, Stand]] = None,
107
+ extra_top: Optional[dict] = None):
108
+ self.path = Path(path)
109
+ self.default = default
110
+ self._stands: dict[str, Stand] = stands or {}
111
+ # Прочие top-level ключи исходного файла (у реестра BPMkit: _comment,
112
+ # scaffold_root, shared_docs_root, default_locked и т.п.) — сохраняются,
113
+ # чтобы save() не затирал их и не портил общий с китом projects.json.
114
+ self._extra_top: dict = extra_top or {}
115
+
116
+ # --- чтение/запись ---
117
+
118
+ @classmethod
119
+ def load(cls, path: str | Path) -> "Registry":
120
+ """
121
+ Читает реестр из файла. Если файла нет — возвращает ПУСТОЙ реестр
122
+ (это нормальная ситуация при первом запуске; см. projects.sample.json
123
+ как образец для ручного заполнения).
124
+ """
125
+ p = Path(path)
126
+ if not p.exists():
127
+ return cls(path=p, default="", stands={})
128
+
129
+ raw = p.read_text(encoding="utf-8-sig")
130
+ try:
131
+ data = json.loads(raw) if raw.strip() else {}
132
+ except json.JSONDecodeError as exc:
133
+ raise RegistryError(f"Некорректный JSON реестра {p}: {exc}") from exc
134
+
135
+ default = data.get("default", "")
136
+ # Реестр BPMkit (и кита) хранит стенды под ключом "projects"; поддерживаем
137
+ # и "stands" для обратной совместимости со старым форматом standkit.
138
+ stands_raw = data.get("projects")
139
+ if stands_raw is None:
140
+ stands_raw = data.get("stands", {})
141
+ extra_top = {k: v for k, v in data.items() if k not in ("projects", "stands", "default")}
142
+ stands = {name: Stand.from_dict(name, rec) for name, rec in stands_raw.items()}
143
+ return cls(path=p, default=default, stands=stands, extra_top=extra_top)
144
+
145
+ def save(self) -> None:
146
+ """Пишет реестр обратно в файл БЕЗ BOM, с отступом для читаемости диффов."""
147
+ # Сохраняем В ФОРМАТЕ BPMkit (ключ "projects") и переносим прочие top-level
148
+ # ключи исходного файла — чтобы не разрушить общий с китом реестр.
149
+ payload: dict = dict(self._extra_top)
150
+ payload["default"] = self.default
151
+ payload["projects"] = {name: stand.to_dict() for name, stand in self._stands.items()}
152
+ text = json.dumps(payload, ensure_ascii=False, indent=2)
153
+ self.path.write_text(text, encoding="utf-8")
154
+
155
+ # --- доступ к записям ---
156
+
157
+ def list(self) -> list[Stand]:
158
+ """Список всех стендов реестра (без гарантии порядка вставки не даётся, но сохраняется)."""
159
+ return list(self._stands.values())
160
+
161
+ def names(self) -> list[str]:
162
+ return list(self._stands.keys())
163
+
164
+ def get(self, name: str) -> Stand:
165
+ """Возвращает Stand по имени. Бросает RegistryError, если такого нет."""
166
+ try:
167
+ return self._stands[name]
168
+ except KeyError as exc:
169
+ raise RegistryError(f"Стенд '{name}' не найден в реестре {self.path}") from exc
170
+
171
+ def get_default(self) -> Stand:
172
+ """Возвращает стенд по умолчанию (поле ``default`` реестра)."""
173
+ if not self.default:
174
+ raise RegistryError("В реестре не задан стенд по умолчанию (default)")
175
+ return self.get(self.default)
176
+
177
+ def __contains__(self, name: str) -> bool:
178
+ return name in self._stands
179
+
180
+ def __len__(self) -> int:
181
+ return len(self._stands)
182
+
183
+ # --- изменение реестра ---
184
+
185
+ def add_existing(self, stand: Stand, *, make_default: bool = False) -> None:
186
+ """
187
+ Привязывает УЖЕ СУЩЕСТВУЮЩИЙ стенд к реестру (не провижининг!).
188
+
189
+ Ожидается, что каталог стенда/БД/дистрибутив уже существуют — этот
190
+ метод только регистрирует их в реестре standkit, чтобы ядро могло
191
+ ими управлять (start/stop/health). Валидирует запись перед добавлением.
192
+ """
193
+ errors = stand.validate()
194
+ if errors:
195
+ raise RegistryError(
196
+ f"Невозможно добавить стенд '{stand.name}': {'; '.join(errors)}"
197
+ )
198
+ if stand.name in self._stands:
199
+ raise RegistryError(f"Стенд '{stand.name}' уже есть в реестре")
200
+ self._stands[stand.name] = stand
201
+ if make_default or not self.default:
202
+ self.default = stand.name
203
+
204
+ def remove(self, name: str) -> None:
205
+ """Удаляет запись стенда из реестра (сам стенд физически не трогает)."""
206
+ if name not in self._stands:
207
+ raise RegistryError(f"Стенд '{name}' не найден в реестре")
208
+ del self._stands[name]
209
+ if self.default == name:
210
+ self.default = next(iter(self._stands), "")
211
+
212
+ def update(self, stand: Stand) -> None:
213
+ """Обновляет существующую запись стенда (по имени)."""
214
+ if stand.name not in self._stands:
215
+ raise RegistryError(f"Стенд '{stand.name}' не найден в реестре, обновлять нечего")
216
+ errors = stand.validate()
217
+ if errors:
218
+ raise RegistryError(f"Невозможно обновить стенд '{stand.name}': {'; '.join(errors)}")
219
+ self._stands[stand.name] = stand
220
+
221
+ def filter_by_transport(self, transport: str) -> Iterable[Stand]:
222
+ """Вспомогательный фильтр — например, для GUI, чтобы отдельно собрать agent-стенды."""
223
+ return (s for s in self._stands.values() if s.transport.value == transport)