s-orchestrationkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ """orchestrationkit — развязанные оркестраторы КОРНЯ китов: домен-нейтральны,
2
+ переиспользуемы любым потребителем.
3
+
4
+ Родился волной 5 платформы китов (Atlas #2710, Ruling 5-3) выделением связного
5
+ кластера из `librarykit`: `SessionLoader`/`HealthMonitor`/`OnboardingService`
6
+ (`orchestrationkit.orchestration`) + носители данных (`orchestrationkit.entities`),
7
+ порты (`orchestrationkit.ports`), канонические перечисления (`orchestrationkit.enums`),
8
+ контракты подключения и здоровья (`orchestrationkit.protocols`) и внутренний
9
+ примитив параллельных проб (`orchestrationkit.fanout`) — три разных внешних
10
+ потребителя (adapterkit, gws, bublictr) держали один и тот же код по чужому
11
+ адресу.
12
+
13
+ **Закон слоёв.** `orchestrationkit` не знает ни одного другого кита экосистемы,
14
+ кроме `corekit` (только `corekit.dto.SessionRef` в `protocols.py` — тонкая точка,
15
+ не библиотека целиком). Никакого импорта `librarykit`/`adapterkit`/`clikit`/
16
+ `bublictr` — оркестраторы стоят на нейтральных `Session`/`SessionContext`/
17
+ `HealthReport`, а доменные привязки (реестр адаптеров, enum'ы `Operation`/
18
+ `TargetKind`, координаты `SessionRef`, audit-store, браузерный профиль)
19
+ ИНЪЕКТИРУЮТСЯ доменом при инстанцировании.
20
+
21
+ `librarykit.orchestration`/`librarykit.entities`/`librarykit.ports`/
22
+ `librarykit.enums`/`librarykit.protocols`/`librarykit.fanout` остаются АЛИАСАМИ
23
+ на этот кит (`sys.modules`-подмена, не реэкспорт) — старые формы доступа
24
+ работают байт-в-байт для 54 потребителей librarykit.
25
+
26
+ Версия читается из метаданных установленного дистрибутива
27
+ (``importlib.metadata``), а не из литерала в коде — второй источник правды
28
+ здесь намеренно не заведён (см. `docs/kits/SAFETY-NET.md` в devcontour,
29
+ release_guard проверяет это на теге).
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from importlib.metadata import PackageNotFoundError, version
34
+
35
+ #: Не `__version__ = "0.0.0+unknown"` строкой-литералом — гейт релиза
36
+ #: (`scripts/release_guard.py`) ищет ЛЮБУЮ строку вида `^__version__\s*=\s*"`
37
+ #: как признак второго источника правды и откажет тегу, даже если это только
38
+ #: аварийный fallback редактируемой установки. Вынесено в константу, чтобы
39
+ #: строка присваивания `__version__` не начиналась с кавычки.
40
+ _UNBUILT_FALLBACK = "0.0.0+unknown"
41
+
42
+ try:
43
+ __version__ = version("s-orchestrationkit")
44
+ except PackageNotFoundError: # pragma: no cover — редактируемая установка без сборки
45
+ __version__ = _UNBUILT_FALLBACK
46
+
47
+ __all__: list[str] = []
@@ -0,0 +1,176 @@
1
+ """Домен-нейтральные data-классы оркестраторов — почва под будущий переезд.
2
+
3
+ Здесь живут НЕЙТРАЛЬНЫЕ копии данных, на которых стоят оркестраторы
4
+ (`OnboardingService`/`HealthMonitor`/`SessionLoader`): `Session`,
5
+ `SessionChanges`, `SessionContext`, `HealthReport`. Развязка от домена:
6
+
7
+ - поле `network` / `target` — ``str`` (машинное имя сети), НЕ доменный enum
8
+ (`NetworkId`/`TargetKind`); оркестратор не должен знать конкретный домен;
9
+ - `HealthReport.state` — `orchestrationkit.enums.HealthState` (канон-нейтральный enum,
10
+ реэкспортируется доменом).
11
+
12
+ ЗАКОН СЛОЁВ: только stdlib (+ `orchestrationkit.enums`); никаких импортов из
13
+ adapterkit/clikit/bublictr. Это data-carriers, поэтому stdlib `@dataclass`
14
+ (orchestrationkit — «почти-stdlib», pydantic НЕ зависимость пакета).
15
+
16
+ Нота: на момент переноса доменные `bublictr.core.entities.Session`/...
17
+ ОСТАЮТСЯ pydantic+`NetworkId` (домен использует ``session.network.value`` и
18
+ сравнения ``== NetworkId.X`` в ~40 файлах + 1168 тестах — развязка bublictr.Session
19
+ на ``str`` слишком инвазивна). Поэтому эти классы — ОТДЕЛЬНЫЕ нейтральные формы
20
+ ПОЗЖЕ, когда оркестраторы переедут на них. `HealthState` же канонизирован и
21
+ реэкспортирован доменом (identity-equal) уже сейчас.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from orchestrationkit.enums import HealthState
31
+
32
+
33
+ @dataclass
34
+ class Session:
35
+ """Логин/токены/storage_state одного аккаунта в одной сети (нейтрально).
36
+
37
+ Нейтральный аналог `bublictr.core.entities.Session`: `network` — ``str``
38
+ (машинное имя сети), а не `NetworkId`. Остальные поля 1:1 с доменной
39
+ версией (включая V5-координаты unified session storage).
40
+
41
+ Несколько Binding могут разделять один Session. `storage_path` — JSON-файл с
42
+ cookies/state; секреты — в keyring под `keyring_ref`.
43
+ """
44
+
45
+ id: str # uuid4
46
+ network: str # машинное имя сети ("vk" / "telegram" / ...)
47
+ storage_path: Path
48
+ created_at: datetime
49
+ keyring_ref: str | None = None
50
+ account_handle: str | None = None
51
+ account_external_id: str | None = None
52
+ last_refreshed_at: datetime | None = None
53
+ expires_at: datetime | None = None
54
+ metadata: dict[str, Any] = field(default_factory=dict)
55
+ # --- V5: координаты unified session storage ---
56
+ profile_id: str | None = None
57
+ account_id: str | None = None
58
+ session_dir_path: Path | None = None
59
+ auth_provider_id: int | None = None
60
+
61
+
62
+ @dataclass
63
+ class SessionChanges:
64
+ """DTO частичного обновления `Session` (нейтрально).
65
+
66
+ Только non-None поля применяются. Immutable после create (не входят сюда):
67
+ id, network, storage_path, created_at.
68
+ """
69
+
70
+ keyring_ref: str | None = None
71
+ account_handle: str | None = None
72
+ account_external_id: str | None = None
73
+ last_refreshed_at: datetime | None = None
74
+ expires_at: datetime | None = None
75
+ metadata: dict[str, Any] | None = None
76
+ # --- V5 ---
77
+ profile_id: str | None = None
78
+ account_id: str | None = None
79
+ session_dir_path: Path | None = None
80
+ auth_provider_id: int | None = None
81
+
82
+
83
+ @dataclass
84
+ class SessionContext:
85
+ """Полный контекст для DI в адаптер — `Session` + storage_state + secrets.
86
+
87
+ Нейтральный аналог `bublictr.usecases.session_loader.SessionContext`.
88
+ """
89
+
90
+ session: Session
91
+ storage_state: dict[str, Any]
92
+ secrets: dict[str, str] # secret_name → value, e.g. {"access_token": "..."}
93
+
94
+
95
+ @dataclass
96
+ class RawHealthStatus:
97
+ """RAW-результат health-check адаптера ДО классификации (нейтрально).
98
+
99
+ Это «сырая» форма, которую отдаёт ``NetworkAdapter.health_check()`` и которую
100
+ жуёт :func:`orchestrationkit.orchestration.health.classify_health_state` → выдаёт
101
+ `HealthState`. Нейтральный аналог `bublictr.core.entities.HealthStatus`:
102
+ `network` — ``str`` (машинное имя сети), а не `NetworkId`.
103
+
104
+ Развязка vs `orchestrationkit.protocols.HealthStatus`: тот — УЖЕ классифицированный
105
+ DTO (несёт `state: HealthState`, для `HealthProtocol`); ЭТОТ — сырой вход
106
+ классификатора (несёт `healthy: bool` + `rate_limit_remaining`, без `state`).
107
+ Раньше оба назывались `HealthStatus` — конфликт снят переименованием этой,
108
+ RAW-формы, в `RawHealthStatus` (старое имя — deprecated-алиас ниже).
109
+
110
+ `classify_health_state` читает поля по duck-typing (`.auth_valid`/
111
+ `.api_reachable`/`.notes`/`.rate_limit_remaining`/`.healthy`), поэтому
112
+ одинаково принимает и эту форму, и доменный pydantic-`HealthStatus` bublictr
113
+ (тот несёт сверху `network: NetworkId` + `raw` + `model_dump` — их домен
114
+ оставляет себе, развязка их не трогает).
115
+ """
116
+
117
+ network: str
118
+ healthy: bool
119
+ auth_valid: bool
120
+ api_reachable: bool
121
+ rate_limit_remaining: int | None = None
122
+ notes: list[str] = field(default_factory=list)
123
+
124
+
125
+ @dataclass
126
+ class HealthReport:
127
+ """Результат периодической проверки binding'а (нейтрально).
128
+
129
+ Нейтральный аналог `bublictr.core.entities.HealthReport`: `network` — ``str``,
130
+ `state` — `orchestrationkit.enums.HealthState` (канон). Добавляет binding/profile
131
+ контекст, классифицированный state и временные метки для trending.
132
+ """
133
+
134
+ binding_id: str
135
+ profile_id: str
136
+ network: str
137
+ state: HealthState
138
+ auth_valid: bool
139
+ api_reachable: bool
140
+ checked_at: datetime
141
+ rate_limit_remaining: int | None = None
142
+ retry_after_sec: int | None = None
143
+ notes: list[str] = field(default_factory=list)
144
+ next_check_at: datetime | None = None
145
+
146
+
147
+ __all__ = [
148
+ "Session",
149
+ "SessionChanges",
150
+ "SessionContext",
151
+ "RawHealthStatus",
152
+ "HealthReport",
153
+ ]
154
+
155
+
156
+ def __getattr__(name: str) -> Any:
157
+ """PEP 562: deprecated-алиас `HealthStatus` → `RawHealthStatus`.
158
+
159
+ RAW-снимок здоровья переименован в `RawHealthStatus`, чтобы снять конфликт
160
+ имён с классифицированным DTO `orchestrationkit.protocols.HealthStatus`. Старое имя
161
+ остаётся импортируемым (`from orchestrationkit.entities import HealthStatus`) ради
162
+ обратной совместимости, но предупреждает о переезде.
163
+ """
164
+ if name == "HealthStatus":
165
+ import warnings
166
+
167
+ warnings.warn(
168
+ "orchestrationkit.entities.HealthStatus переименован в RawHealthStatus. "
169
+ "Имя HealthStatus теперь закреплено за классифицированным DTO "
170
+ "orchestrationkit.protocols.HealthStatus; RAW-снимок стал RawHealthStatus. "
171
+ "Обнови импорт на RawHealthStatus — старый алиас будет удалён позже.",
172
+ DeprecationWarning,
173
+ stacklevel=2,
174
+ )
175
+ return RawHealthStatus
176
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,79 @@
1
+ """Домен-нейтральные enums КОРНЯ китов (orchestrationkit).
2
+
3
+ Канон состояний здоровья сессии (`HealthState`) живёт ЗДЕСЬ — единый источник
4
+ истины для всего графа (orchestrationkit <- adapterkit <- clikit) и для домена
5
+ (bublictr реэкспортирует его из `bublictr.core.enums`). ``orchestrationkit.protocols``
6
+ реэкспортирует `HealthState` отсюда — НЕ плодим второй enum.
7
+
8
+ Рядом с ним — `SsoState`: живость входа У ПОСТАВЩИКА ЛИЧНОСТИ. Это ВТОРАЯ ось
9
+ того же вопроса «можно ли работать», и жить она обязана там же, где первая:
10
+ сессия сервиса и вход у Google умирают порознь, а состояния их читает один и
11
+ тот же оркестратор. Разведи эти два перечисления по разным китам — и потребитель
12
+ вынужден тянуть кит пула аккаунтов ради одного слова «alive».
13
+
14
+ ЗАКОН СЛОЁВ: только stdlib; никаких импортов из adapterkit/clikit/bublictr.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from enum import StrEnum
19
+
20
+
21
+ class HealthState(StrEnum):
22
+ """Агрегированное состояние здоровья сессии/binding'а (домен-нейтрально).
23
+
24
+ StrEnum — значение совпадает со строкой, прямая сериализация в JSON и
25
+ сравнение с raw-ответами. Канон тут; `orchestrationkit.protocols.HealthState`,
26
+ `bublictr.core.enums.HealthState` — реэкспорт этого объекта (identity-equal).
27
+
28
+ - `HEALTHY` — всё работает;
29
+ - `WARNING` — работает, но накопились issues;
30
+ - `DEGRADED` — частично сломано;
31
+ - `UNAUTHENTICATED` — сессия разлогинена → нужен re-login;
32
+ - `RATE_LIMITED` — упёрлись в лимит запросов;
33
+ - `UNREACHABLE` — API недоступно (сеть/сервер);
34
+ - `CAPTCHA` — требуется challenge/ручное действие;
35
+ - `UNKNOWN` — состояние не удалось определить.
36
+ """
37
+
38
+ HEALTHY = "healthy"
39
+ WARNING = "warning"
40
+ DEGRADED = "degraded"
41
+ UNAUTHENTICATED = "unauthenticated"
42
+ RATE_LIMITED = "rate_limited"
43
+ UNREACHABLE = "unreachable"
44
+ CAPTCHA = "captcha"
45
+ UNKNOWN = "unknown"
46
+
47
+
48
+ class SsoState(StrEnum):
49
+ """Живость входа У ПОСТАВЩИКА ЛИЧНОСТИ — отдельно от сессии сервиса.
50
+
51
+ Аккаунт, заведённый «через Google», держится на ДВУХ входах сразу, и сроки
52
+ жизни у них разные: ChatGPT продолжает отвечать, когда войти в Google уже
53
+ нельзя. Поэтому вопрос «жив ли общий вход» — свой, и ответ у него свой.
54
+
55
+ ИСХОДОВ ТРИ, И ТРЕТИЙ НЕ РОСКОШЬ.
56
+
57
+ - ``ALIVE`` — поставщик НАЗВАЛ, кого узнал. Ответ «200 OK» сам по себе
58
+ не признание: гостю сервисы отвечают ровно так же;
59
+ - ``LOGGED_OUT`` — только по ПРЯМОЙ улике отзыва (поставщик предъявил форму
60
+ входа; он ответил 401/403). Улику при этом обязаны предъявить: вердикт без
61
+ улики непроверяем, а по непроверяемому выключают рабочие аккаунты;
62
+ - ``UNKNOWN`` — всё прочее: связь оборвалась, ответ невнятен, проверки не
63
+ было. «Не знаю» — рабочий ответ, оставляющий аккаунт в строю.
64
+
65
+ Схлопывание неизвестного в «разлогинен» выключает живые аккаунты;
66
+ схлопывание в «жив» прячет поломку до часа, когда войти надо, а нечем.
67
+
68
+ StrEnum — значение совпадает со строкой (прямая сериализация в JSON и
69
+ сравнение с тем, что уже записано в реестрах). Канон ТУТ; кит пула аккаунтов
70
+ (`accountpoolkit.domain.enums.SsoState`) держит те же три значения и
71
+ переезжает на реэкспорт этого объекта — НЕ второй enum.
72
+ """
73
+
74
+ ALIVE = "alive"
75
+ LOGGED_OUT = "logged_out"
76
+ UNKNOWN = "unknown"
77
+
78
+
79
+ __all__ = ["HealthState", "SsoState"]
@@ -0,0 +1,181 @@
1
+ """ВЕЕР: N параллельных задач → результаты И привязанные к ним ошибки.
2
+
3
+ ЗАЧЕМ. Веерные пути кита («спроси три канала», «проверь здоровье десяти
4
+ привязок», «опубликуй в четыре сети») делались голым ``asyncio.gather``, и это
5
+ даёт ровно то, чего в таких сценариях быть не должно:
6
+
7
+ * **первое же исключение выбрасывается наружу, а остальные результаты теряются.**
8
+ Три канала: два ответили, третий упал — вызывающий не получает НИЧЕГО, хотя два
9
+ ответа у него в руках. Веер именно тем и ценен, что частичный успех — успех;
10
+ * **осиротевшие задачи.** ``gather`` при пробросе исключения НЕ отменяет
11
+ соседей: они продолжают выполняться уже никем не ожидаемые, дописывают файлы,
12
+ жгут лимиты, а их ошибки всплывают позже как ``Task exception was never
13
+ retrieved``;
14
+ * **``return_exceptions=True`` тоже не лечит.** Он отдаёт плоский список, где
15
+ исключение лежит НА МЕСТЕ результата и не привязано к тому, ЧЕЙ это результат:
16
+ вызывающий восстанавливает соответствие по индексу и на первой же правке
17
+ порядка каналов начинает винить не того.
18
+
19
+ КАК ЗДЕСЬ. :func:`fan_out` строит структурную конкурентность на
20
+ :class:`asyncio.TaskGroup` (PEP 654, стандартный механизм): у задач есть ОДИН
21
+ владелец, при выходе из блока не остаётся ни одной живой, а отмена извне
22
+ корректно доезжает до всех. Исключения при этом НЕ рвут группу: каждая работа
23
+ запускается в обёртке, которая ловит своё исключение и кладёт его в результат под
24
+ СВОИМ КЛЮЧОМ. Итог — :class:`FanOut`: ``results`` (кто ответил) и ``errors``
25
+ (кто упал и чем именно).
26
+
27
+ ЧТО НЕ ЛОВИТСЯ НАМЕРЕННО: `asyncio.CancelledError`. Отмена — не результат работы,
28
+ а приказ извне; проглотить её значит сломать отмену всего дерева задач.
29
+
30
+ ЧИСТЫЙ модуль: только stdlib, ``asyncio`` импортируется ЛЕНИВО (ядро не имеет
31
+ права тянуть event loop на ``import orchestrationkit`` — см. `test_lazy_facade`).
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Awaitable, Callable, Hashable, Mapping, Sequence
36
+ from dataclasses import dataclass, field
37
+ from typing import Any, TypeVar
38
+
39
+ __all__ = ["FanOut", "FanOutError", "fan_out", "fan_out_all"]
40
+
41
+ K = TypeVar("K", bound=Hashable)
42
+ V = TypeVar("V")
43
+
44
+ #: Работа веера: либо корутина/awaitable, либо фабрика корутин (``lambda: coro()``).
45
+ #: Фабрика предпочтительнее для ленивых и повторяемых сценариев; готовый awaitable
46
+ #: поддержан ради краткости вызова.
47
+ Job = Awaitable[Any] | Callable[[], Awaitable[Any]]
48
+
49
+
50
+ class FanOutError(Exception):
51
+ """Ни одна ветка веера не отработала — поднимает :meth:`FanOut.unwrap`.
52
+
53
+ Несёт ``errors`` целиком: «всё упало» почти всегда означает общую причину
54
+ (лёг выход, протухла сессия), и вызывающему нужны ВСЕ улики, а не первая.
55
+ """
56
+
57
+ def __init__(self, message: str, errors: Mapping[Any, BaseException]) -> None:
58
+ super().__init__(message)
59
+ self.errors = dict(errors)
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class FanOut:
64
+ """Итог веера: кто ответил и кто упал — с сохранением ЧЕЙ это ответ/ошибка.
65
+
66
+ * ``results`` — ключ → значение по успешным веткам;
67
+ * ``errors`` — ключ → исключение по упавшим (само исключение, не текст:
68
+ вызывающий волен разобрать его типом, а не регуляркой по сообщению).
69
+
70
+ Порядок ключей сохраняется от входа — веер отвечает вразнобой, но отчёт
71
+ человеку должен быть стабильным.
72
+ """
73
+
74
+ results: dict[Any, Any] = field(default_factory=dict)
75
+ errors: dict[Any, BaseException] = field(default_factory=dict)
76
+
77
+ @property
78
+ def ok(self) -> bool:
79
+ """Все ветки отработали."""
80
+ return not self.errors
81
+
82
+ @property
83
+ def partial(self) -> bool:
84
+ """Часть ответила, часть упала — типовой и ЛЕГАЛЬНЫЙ исход веера."""
85
+ return bool(self.results) and bool(self.errors)
86
+
87
+ def unwrap(self) -> dict[Any, Any]:
88
+ """Результаты, если ответила ХОТЬ ОДНА ветка; иначе `FanOutError`.
89
+
90
+ Для сценариев «мне достаточно любого ответа» (спроси три канала, возьми
91
+ что дали). Тем, кому нужны все, — смотреть ``errors`` самому либо звать
92
+ :func:`fan_out_all`.
93
+ """
94
+ if not self.results:
95
+ raise FanOutError(
96
+ f"веер не дал ни одного результата ({len(self.errors)} веток упало)",
97
+ self.errors,
98
+ )
99
+ return dict(self.results)
100
+
101
+ def __len__(self) -> int:
102
+ return len(self.results) + len(self.errors)
103
+
104
+
105
+ async def _await_job(job: Job) -> Any:
106
+ """Развернуть работу: фабрику — вызвать, awaitable — дождаться."""
107
+ return await (job() if callable(job) else job)
108
+
109
+
110
+ async def fan_out(
111
+ jobs: Mapping[K, Job] | Sequence[Job],
112
+ *,
113
+ limit: int | None = None,
114
+ ) -> FanOut:
115
+ """Выполнить работы ПАРАЛЛЕЛЬНО; вернуть результаты И привязанные ошибки.
116
+
117
+ ``jobs`` — словарь «ключ → работа» (ключ уедет в ``results``/``errors``) либо
118
+ последовательность, и тогда ключами станут индексы. ``limit`` ограничивает
119
+ число одновременно исполняемых работ (семафор) — для веера по десяткам
120
+ привязок, где залп по всем сразу упрётся в лимиты провайдера.
121
+
122
+ Падение одной ветки НЕ отменяет остальные и НЕ выбрасывается наружу — оно
123
+ приезжает в ``errors`` под своим ключом. Наружу проходит только отмена
124
+ (`asyncio.CancelledError`): её глотать нельзя.
125
+ """
126
+ import asyncio # лениво: ядро не тянет event loop на импорте
127
+
128
+ items: list[tuple[Any, Job]] = (
129
+ list(jobs.items()) if isinstance(jobs, Mapping) else list(enumerate(jobs))
130
+ )
131
+ results: dict[Any, Any] = {}
132
+ errors: dict[Any, BaseException] = {}
133
+ if not items:
134
+ return FanOut(results, errors)
135
+
136
+ semaphore = asyncio.Semaphore(limit) if limit and limit > 0 else None
137
+
138
+ async def run(key: Any, job: Job) -> None:
139
+ try:
140
+ if semaphore is None:
141
+ results[key] = await _await_job(job)
142
+ else:
143
+ async with semaphore:
144
+ results[key] = await _await_job(job)
145
+ except asyncio.CancelledError:
146
+ # Отмена — приказ извне, а не исход работы: пробрасываем как есть,
147
+ # иначе `TaskGroup` не сможет свернуть дерево задач.
148
+ raise
149
+ except BaseException as exc:
150
+ errors[key] = exc
151
+
152
+ async with asyncio.TaskGroup() as group:
153
+ for key, job in items:
154
+ group.create_task(run(key, job))
155
+
156
+ # Порядок ключей — как на входе: отчёт человеку обязан быть стабильным.
157
+ ordered_keys = [key for key, _ in items]
158
+ return FanOut(
159
+ {k: results[k] for k in ordered_keys if k in results},
160
+ {k: errors[k] for k in ordered_keys if k in errors},
161
+ )
162
+
163
+
164
+ async def fan_out_all(
165
+ jobs: Mapping[K, Job] | Sequence[Job],
166
+ *,
167
+ limit: int | None = None,
168
+ ) -> dict[Any, Any]:
169
+ """Веер «нужны ВСЕ»: любая упавшая ветка → `FanOutError` со ВСЕМИ ошибками.
170
+
171
+ Отличие от голого ``gather``: соседи всё равно доработали (их результаты уже
172
+ получены), задачи не осиротели, а в исключении лежат ВСЕ причины с
173
+ привязкой к ключам, а не одна первая.
174
+ """
175
+ outcome = await fan_out(jobs, limit=limit)
176
+ if outcome.errors:
177
+ keys = ", ".join(repr(k) for k in outcome.errors)
178
+ raise FanOutError(
179
+ f"веер требовал всех, упали ветки: {keys}", outcome.errors
180
+ )
181
+ return dict(outcome.results)
@@ -0,0 +1,43 @@
1
+ """Развязанные оркестраторы КОРНЯ китов: переиспользуемы, домен-нейтральны.
2
+
3
+ Кульминация переезда: `SessionLoader`/`HealthMonitor`/`OnboardingService` +
4
+ классификатор здоровья + probe-registry + generic adapter probe живут ЗДЕСЬ, а не
5
+ в bublictr. Они переиспользуемы любым потребителем (bublictr / reverse-factory):
6
+
7
+ - стоят на `orchestrationkit.entities` (нейтральные `Session`/`SessionContext`/
8
+ `HealthReport`/`RawHealthStatus`, `network: str`) и `orchestrationkit.enums.HealthState`;
9
+ - репо/сторы/probe инжектируются как `orchestrationkit.ports`-Protocol;
10
+ - доменные привязки (реестр адаптеров, enum-значения `Operation`/`TargetKind`,
11
+ координаты `SessionRef`, audit-store, браузерный профиль) ИНЪЕКТИРУЮТСЯ доменом
12
+ при инстанцировании (bublictr — в `composition.py`).
13
+
14
+ ЗАКОН СЛОЁВ: ни один модуль пакета НЕ импортит adapterkit/clikit/bublictr и не
15
+ знает `NetworkId`/registry — всё инжектится. Граф китов: orchestrationkit <- adapterkit
16
+ <- clikit; orchestrationkit не зависит ни от кого выше.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from orchestrationkit.orchestration.health import (
21
+ GenericAdapterHealthProbe,
22
+ HealthMonitor,
23
+ classify_health_state,
24
+ get_health_probe,
25
+ register_health_probe,
26
+ )
27
+ from orchestrationkit.orchestration.onboarding import OnboardingDeps, OnboardingService
28
+ from orchestrationkit.orchestration.session_loader import SessionContext, SessionLoader
29
+
30
+ __all__ = [
31
+ # session_loader
32
+ "SessionLoader",
33
+ "SessionContext",
34
+ # health
35
+ "classify_health_state",
36
+ "register_health_probe",
37
+ "get_health_probe",
38
+ "HealthMonitor",
39
+ "GenericAdapterHealthProbe",
40
+ # onboarding
41
+ "OnboardingService",
42
+ "OnboardingDeps",
43
+ ]