fadenstack 0.4.2__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.
Files changed (99) hide show
  1. faden_cli/__init__.py +16 -0
  2. faden_cli/__main__.py +9 -0
  3. faden_cli/bundle/shared/.gitignore +25 -0
  4. faden_cli/bundle/shared/LICENSE +187 -0
  5. faden_cli/bundle/shared/NOTICE +15 -0
  6. faden_cli/bundle/shared/OFFICIAL.md +9 -0
  7. faden_cli/bundle/shared/README.md +16 -0
  8. faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
  9. faden_cli/bundle/shared/alloy/config.alloy +40 -0
  10. faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
  11. faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
  12. faden_cli/bundle/shared/docker-compose.yaml +998 -0
  13. faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
  14. faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
  15. faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
  16. faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
  17. faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
  18. faden_cli/bundle/shared/initdb/01-init.sql +32 -0
  19. faden_cli/bundle/shared/loki/local-config.yaml +32 -0
  20. faden_cli/bundle/shared/nginx/locations.conf +245 -0
  21. faden_cli/bundle/shared/nginx/maintenance.html +239 -0
  22. faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
  23. faden_cli/bundle/shared/nginx/nginx.conf +44 -0
  24. faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
  25. faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
  26. faden_cli/bundle/shared/nginx/select-config.sh +37 -0
  27. faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
  28. faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
  29. faden_cli/bundle/shared/tls/README.md +30 -0
  30. faden_cli/cli.py +158 -0
  31. faden_cli/commands/__init__.py +1 -0
  32. faden_cli/commands/admin.py +161 -0
  33. faden_cli/commands/backup.py +159 -0
  34. faden_cli/commands/config.py +146 -0
  35. faden_cli/commands/deploy.py +949 -0
  36. faden_cli/commands/dev/__init__.py +1 -0
  37. faden_cli/commands/dev/dev_doctor.py +46 -0
  38. faden_cli/commands/dev/dev_down.py +241 -0
  39. faden_cli/commands/dev/dev_group.py +24 -0
  40. faden_cli/commands/dev/dev_init.py +710 -0
  41. faden_cli/commands/dev/dev_pull.py +297 -0
  42. faden_cli/commands/dev/dev_status.py +151 -0
  43. faden_cli/commands/dev/dev_up.py +1392 -0
  44. faden_cli/commands/doctor.py +225 -0
  45. faden_cli/commands/down.py +75 -0
  46. faden_cli/commands/logs_cmd.py +41 -0
  47. faden_cli/commands/maintenance.py +74 -0
  48. faden_cli/commands/module.py +113 -0
  49. faden_cli/commands/observe.py +95 -0
  50. faden_cli/commands/ports.py +91 -0
  51. faden_cli/commands/rebuild.py +158 -0
  52. faden_cli/commands/restore.py +139 -0
  53. faden_cli/commands/runtime_images.py +174 -0
  54. faden_cli/commands/status.py +71 -0
  55. faden_cli/commands/tls.py +722 -0
  56. faden_cli/commands/tune.py +37 -0
  57. faden_cli/commands/up.py +64 -0
  58. faden_cli/commands/upgrade.py +470 -0
  59. faden_cli/commands/version.py +78 -0
  60. faden_cli/core/__init__.py +1 -0
  61. faden_cli/core/api_client.py +77 -0
  62. faden_cli/core/backup.py +717 -0
  63. faden_cli/core/bootstrap.py +291 -0
  64. faden_cli/core/bundle.py +110 -0
  65. faden_cli/core/clickhouse.py +61 -0
  66. faden_cli/core/compose.py +484 -0
  67. faden_cli/core/console.py +106 -0
  68. faden_cli/core/detect.py +580 -0
  69. faden_cli/core/dev_modules.py +149 -0
  70. faden_cli/core/docker_env.py +147 -0
  71. faden_cli/core/edge.py +94 -0
  72. faden_cli/core/env_gen.py +598 -0
  73. faden_cli/core/git.py +219 -0
  74. faden_cli/core/install.py +288 -0
  75. faden_cli/core/installation.py +186 -0
  76. faden_cli/core/local_node.py +719 -0
  77. faden_cli/core/maintenance.py +82 -0
  78. faden_cli/core/private_files.py +133 -0
  79. faden_cli/core/registry.py +574 -0
  80. faden_cli/core/release_feed.py +342 -0
  81. faden_cli/core/rmq.py +135 -0
  82. faden_cli/core/runtime_images.py +309 -0
  83. faden_cli/core/schedule.py +278 -0
  84. faden_cli/core/settings.py +159 -0
  85. faden_cli/core/sysinfo.py +303 -0
  86. faden_cli/core/tls.py +790 -0
  87. faden_cli/core/tune.py +154 -0
  88. faden_cli/core/update_check.py +254 -0
  89. faden_cli/core/workspace.py +155 -0
  90. faden_cli/templates/env.j2 +33 -0
  91. faden_cli/tui/__init__.py +1 -0
  92. faden_cli/tui/widgets/__init__.py +1 -0
  93. faden_cli/tui/wizard/__init__.py +1 -0
  94. fadenstack-0.4.2.dist-info/METADATA +304 -0
  95. fadenstack-0.4.2.dist-info/RECORD +99 -0
  96. fadenstack-0.4.2.dist-info/WHEEL +4 -0
  97. fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
  98. fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
  99. fadenstack-0.4.2.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,159 @@
1
+ """Configuration file (faden.yaml) read/write utilities.
2
+
3
+ The CLI persists its state in a YAML file located at:
4
+ - ``$FADEN_CONFIG`` env var (if set)
5
+ - ``<install_dir>/faden.yaml``
6
+ - ``%LOCALAPPDATA%/faden/faden.yaml`` (Windows)
7
+ - ``~/.config/faden/faden.yaml`` (Linux/macOS)
8
+
9
+ The schema is intentionally flat: top-level scalars for the common
10
+ case, with a ``dev:`` section for dev-mode specifics.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import yaml
21
+
22
+ from faden_cli.core.registry import (
23
+ REPO_DIR_MAP,
24
+ REPO_NAMES,
25
+ repo_clone_url,
26
+ )
27
+
28
+ # Re-export for backward compatibility
29
+ __all__ = ["REPO_DIR_MAP", "repo_clone_url"]
30
+
31
+
32
+ # ── Default paths ─────────────────────────────────────────────────
33
+
34
+
35
+ def _default_config_dir() -> Path:
36
+ """Return platform-appropriate config directory."""
37
+ if os.name == "nt":
38
+ base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
39
+ if base:
40
+ return Path(base) / "faden"
41
+ return Path.home() / ".config" / "faden"
42
+
43
+
44
+ _DEFAULT_CONFIG_DIR = _default_config_dir()
45
+ _DEFAULT_CONFIG_FILE = _DEFAULT_CONFIG_DIR / "faden.yaml"
46
+
47
+
48
+ @dataclass
49
+ class DevConfig:
50
+ """Dev-mode specific configuration."""
51
+
52
+ workspace_dir: str = ""
53
+ clone_method: str = "https" # "https" or "ssh"
54
+ branch: str = "main"
55
+ repos: list[str] = field(default_factory=lambda: list(REPO_NAMES))
56
+ github_token: str = "" # GitHub PAT or OAuth token for private repos
57
+
58
+
59
+ @dataclass
60
+ class FadenConfig:
61
+ """Top-level CLI configuration."""
62
+
63
+ version: int = 1
64
+ install_dir: str = ""
65
+ compose_file: str = "docker-compose.yaml"
66
+ compose_dev_file: str = "docker-compose.dev.yaml"
67
+ profiles: list[str] = field(default_factory=list)
68
+ admin_email: str = ""
69
+ api_url: str = "http://localhost:8000"
70
+ api_token: str = ""
71
+ #: Which runtime images deploy/upgrade put on this server, by the
72
+ #: architecture of the machines that will run models: "all", "none", or
73
+ #: a list such as "x86_64" or "x86_64,aarch64".
74
+ runtime_images: str = "all"
75
+ #: Whether commands say when a newer release is out (core/update_check.py); deploy asks.
76
+ update_check: bool = True
77
+ #: stable, or preview to hear about release candidates too.
78
+ update_channel: str = "stable"
79
+ #: Where the release feed is read; empty: fadenstack.com. A mirror for sites without internet.
80
+ update_feed_url: str = ""
81
+ dev: DevConfig = field(default_factory=DevConfig)
82
+
83
+ # ── Derived paths ─────────────────────────────────────────
84
+
85
+ @property
86
+ def install_path(self) -> Path:
87
+ """Resolved install directory."""
88
+ return Path(self.install_dir).expanduser().resolve() if self.install_dir else Path.cwd()
89
+
90
+ @property
91
+ def compose_path(self) -> Path:
92
+ """Path to the main docker-compose file."""
93
+ return self.install_path / self.compose_file
94
+
95
+ @property
96
+ def compose_dev_path(self) -> Path:
97
+ """Path to the dev overlay docker-compose file."""
98
+ return self.install_path / self.compose_dev_file
99
+
100
+ @property
101
+ def env_path(self) -> Path:
102
+ """Path to the .env file."""
103
+ return self.install_path / ".env"
104
+
105
+ @property
106
+ def dev_workspace_path(self) -> Path:
107
+ """Resolved dev workspace directory."""
108
+ if self.dev.workspace_dir:
109
+ return Path(self.dev.workspace_dir).expanduser().resolve()
110
+ return Path.cwd()
111
+
112
+
113
+ # ── Read/write ────────────────────────────────────────────────────
114
+
115
+
116
+ def config_path() -> Path:
117
+ """Return the active config file path."""
118
+ env = os.environ.get("FADEN_CONFIG")
119
+ if env:
120
+ return Path(env).expanduser().resolve()
121
+ return _DEFAULT_CONFIG_FILE
122
+
123
+
124
+ def load_config() -> FadenConfig:
125
+ """Load the config from disk, or return defaults if missing."""
126
+ path = config_path()
127
+ if not path.exists():
128
+ return FadenConfig()
129
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
130
+ return _from_dict(data)
131
+
132
+
133
+ def save_config(cfg: FadenConfig) -> Path:
134
+ """Write the config to disk. Creates parent dirs as needed."""
135
+ path = config_path()
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ path.write_text(yaml.dump(_to_dict(cfg), default_flow_style=False, sort_keys=False), encoding="utf-8")
138
+ return path
139
+
140
+
141
+ # ── Serialisation helpers ─────────────────────────────────────────
142
+
143
+
144
+ def _from_dict(data: dict[str, Any]) -> FadenConfig:
145
+ """Build a ``FadenConfig`` from a raw dict."""
146
+ dev_data = data.pop("dev", {}) or {}
147
+ dev = DevConfig(**{k: v for k, v in dev_data.items() if k in DevConfig.__dataclass_fields__})
148
+ cfg = FadenConfig(
149
+ **{k: v for k, v in data.items() if k in FadenConfig.__dataclass_fields__ and k != "dev"},
150
+ dev=dev,
151
+ )
152
+ return cfg
153
+
154
+
155
+ def _to_dict(cfg: FadenConfig) -> dict[str, Any]:
156
+ """Serialise a ``FadenConfig`` to a plain dict for YAML."""
157
+ from dataclasses import asdict
158
+
159
+ return asdict(cfg)
@@ -0,0 +1,303 @@
1
+ """System resource detection and scalability tuning calculator.
2
+
3
+ Uses ``psutil`` to detect host hardware (CPU cores, RAM) and derives
4
+ optimal worker counts, DB pool sizes, and message-queue pool sizes
5
+ for all Fadenstack services.
6
+
7
+ The control plane is sized to use only a fraction of the host
8
+ (``DEFAULT_RESOURCE_PCT``, default 25 %) so that the majority of
9
+ CPU and RAM remain available for inference engines (vLLM, etc.)
10
+ and other workloads.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import os
17
+ from dataclasses import dataclass, field
18
+
19
+ import psutil
20
+
21
+ # ── Resource budget ───────────────────────────────────────────────
22
+ # Percentage of total host resources reserved for the Fadenstack
23
+ # control plane. The remainder is left for inference / ML workloads.
24
+ DEFAULT_RESOURCE_PCT: float = 0.25
25
+
26
+ # ── Service classification ────────────────────────────────────────
27
+ # Heavy services handle high-volume request traffic or CPU-bound work.
28
+ # Light services are low-traffic or background-only.
29
+
30
+ HEAVY_SERVICES = ("backend", "gateway", "pii")
31
+ LIGHT_SERVICES: tuple[str, ...] = ()
32
+ ALL_SERVICES = HEAVY_SERVICES + LIGHT_SERVICES
33
+
34
+ # Services whose PostgreSQL pool the CLI sizes. PII has pool settings but
35
+ # opens no connection: its database layer is unused.
36
+ DB_SERVICES = ("backend", "gateway")
37
+
38
+ # Services that use a RabbitMQ connection pool.
39
+ RABBIT_SERVICES = ("backend", "gateway")
40
+
41
+ # ── Pools: per worker process, whatever the number of workers ─────
42
+ # Each worker process keeps its own pools. They were sized by the worker
43
+ # count too -- 3 connections per worker, as many again on demand -- so what a
44
+ # service could open grew with the square of its workers: 24 at 8 cores, 96 at
45
+ # 16, 384 at 32, per service, past any Postgres.
46
+ DB_POOL_PER_WORKER = 5
47
+ DB_OVERFLOW_PER_WORKER = {"dev": 10, "prod": 5}
48
+ RABBIT_POOL_PER_WORKER = 2
49
+ RABBIT_CHANNELS_PER_WORKER = 10
50
+
51
+ # ── Postgres connections ──────────────────────────────────────────
52
+ #: Processes of the backend worker container (taskiq's default), each with
53
+ #: the backend's pool.
54
+ BACKEND_TASK_WORKERS = 2
55
+ #: Services with pools the CLI does not size: 1 worker x (5 + 10).
56
+ UNTUNED_CONNECTIONS = {"mcp": 15, "skills": 15}
57
+ #: Langfuse's web and worker, migrations, backups, psql.
58
+ CONNECTION_HEADROOM = 100
59
+ #: The shared compose file's default; tuning never goes below it.
60
+ MIN_MAX_CONNECTIONS = 250
61
+
62
+ # ── Memory: what each part takes (docker stats, release VM, 2026-09-28) ──
63
+ # Sized by CPU alone, a 7 GB server got two of everything: a model download
64
+ # took the task worker to 2.3 GB, swap filled, the kernel killed the worker,
65
+ # the web backend's supervisor replaced a child that had stopped answering,
66
+ # and at last the VM stopped answering at all. So the workers are cut to
67
+ # what the memory holds. Measured after start-up with every module on; PII
68
+ # loads its language models in each web worker and in its task worker.
69
+ #: The operating system and Docker, and some page cache to breathe: 1 GB plus 5 %.
70
+ OS_RESERVE_MB = 1024
71
+ OS_RESERVE_PCT = 0.05
72
+ #: The services that run once whatever the sizing: the gateway's task worker
73
+ #: ~440, ClickHouse ~290, Postgres ~250, Alloy ~210, the web app ~190,
74
+ #: Grafana ~180, the console ~150, Loki, RabbitMQ (capped at 512), MinIO,
75
+ #: Prometheus, Redis, nginx.
76
+ FIXED_MB = 2100
77
+ #: One worker process of each service ``faden tune`` sizes.
78
+ PER_WORKER_MB = {"backend": 200, "gateway": 290, "pii": 780}
79
+ #: The optional modules' own processes (PII's task worker holds its models too).
80
+ MODULE_MB = {"mcp": 110, "skills": 90, "pii": 1550}
81
+ #: One process of the backend's task worker (taskiq), before any download.
82
+ TASK_WORKER_MB = 160
83
+ #: A model download holds up to one xet range (64 MB) per range it fetches at once.
84
+ XET_RANGE_MB = 64
85
+ #: Ranges fetched at once: fewer on a small server, never so few a download crawls.
86
+ MIN_DOWNLOAD_RANGES, MAX_DOWNLOAD_RANGES = 4, 32
87
+
88
+
89
+ # ── System snapshot ───────────────────────────────────────────────
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class SystemInfo:
94
+ """Point-in-time snapshot of host hardware resources."""
95
+
96
+ physical_cores: int
97
+ logical_cores: int
98
+ total_ram_bytes: int
99
+
100
+ @property
101
+ def total_ram_gb(self) -> float:
102
+ return round(self.total_ram_bytes / (1024**3), 1)
103
+
104
+
105
+ def detect_system() -> SystemInfo:
106
+ """Return a :class:`SystemInfo` snapshot of the current host."""
107
+ return SystemInfo(
108
+ physical_cores=psutil.cpu_count(logical=False) or os.cpu_count() or 1,
109
+ logical_cores=psutil.cpu_count(logical=True) or os.cpu_count() or 1,
110
+ total_ram_bytes=psutil.virtual_memory().total,
111
+ )
112
+
113
+
114
+ # ── Tune profile ─────────────────────────────────────────────────
115
+
116
+
117
+ @dataclass
118
+ class TuneProfile:
119
+ """Computed scalability parameters for all services."""
120
+
121
+ profile: str # "dev" or "prod"
122
+ system: SystemInfo
123
+ resource_pct: float = DEFAULT_RESOURCE_PCT
124
+
125
+ # Per-service worker counts (service name → count).
126
+ workers: dict[str, int] = field(default_factory=dict)
127
+
128
+ # DB pool sizes (service name → pool_size).
129
+ db_pool_size: dict[str, int] = field(default_factory=dict)
130
+ db_max_overflow: dict[str, int] = field(default_factory=dict)
131
+
132
+ # RabbitMQ pool sizes (service name → pool_size).
133
+ rabbit_pool_size: dict[str, int] = field(default_factory=dict)
134
+ rabbit_channel_pool_size: dict[str, int] = field(default_factory=dict)
135
+
136
+ #: Optional modules switched on ("pii", "mcp", "skills"): they take memory.
137
+ modules: frozenset[str] = frozenset()
138
+ #: Processes of the backend's task worker (taskiq ``--workers``).
139
+ task_workers: int = BACKEND_TASK_WORKERS
140
+ #: Ranges a model download fetches at once (``HF_XET_NUM_CONCURRENT_RANGE_GETS``).
141
+ download_ranges: int = MAX_DOWNLOAD_RANGES
142
+ #: Workers the CPU budget allowed, before the memory had its say.
143
+ cpu_workers: int = 0
144
+
145
+ # ── Prefix map ────────────────────────────────────────────
146
+ _ENV_PREFIX: dict[str, str] = field(
147
+ default_factory=lambda: {
148
+ "backend": "FADEN_BACKEND_",
149
+ "gateway": "FADEN_GATEWAY_",
150
+ "pii": "FADEN_PII_",
151
+ },
152
+ repr=False,
153
+ )
154
+
155
+ @property
156
+ def budget_cores(self) -> int:
157
+ """Number of CPU cores allocated to the control plane."""
158
+ return max(1, int(self.system.physical_cores * self.resource_pct))
159
+
160
+ def db_connections(self) -> dict[str, int]:
161
+ """What each database client can open at its peak: workers x (kept + on demand)."""
162
+ per_worker = {svc: self.db_pool_size[svc] + self.db_max_overflow[svc] for svc in DB_SERVICES}
163
+ peaks = {svc: self.workers[svc] * per_worker[svc] for svc in DB_SERVICES}
164
+ peaks["backend-worker"] = self.task_workers * per_worker["backend"]
165
+ peaks.update(UNTUNED_CONNECTIONS)
166
+ return peaks
167
+
168
+ @property
169
+ def memory_budget_mb(self) -> int:
170
+ """The memory Fadenstack may take on this host: all of it but the OS reserve."""
171
+ total_mb = self.system.total_ram_bytes // (1024**2)
172
+ return max(0, int(total_mb - OS_RESERVE_MB - total_mb * OS_RESERVE_PCT))
173
+
174
+ def memory_mb(self) -> dict[str, int]:
175
+ """What each part takes at its sizing, in MB: at rest, and a download under way."""
176
+ parts = {"services that run once": FIXED_MB}
177
+ for module in sorted(self.modules):
178
+ parts[f"{module} module"] = MODULE_MB.get(module, 0)
179
+ for svc in ALL_SERVICES:
180
+ if svc == "pii" and "pii" not in self.modules:
181
+ continue
182
+ parts[f"{svc} x{self.workers[svc]}"] = self.workers[svc] * PER_WORKER_MB[svc]
183
+ parts[f"task worker x{self.task_workers}"] = self.task_workers * TASK_WORKER_MB
184
+ parts["a model download"] = self.download_ranges * XET_RANGE_MB
185
+ return parts
186
+
187
+ @property
188
+ def memory_short_mb(self) -> int:
189
+ """How much more memory the smallest sizing needs than this host has; 0 when it fits."""
190
+ return max(0, sum(self.memory_mb().values()) - self.memory_budget_mb)
191
+
192
+ def _fit_memory(self) -> None:
193
+ """Cut the workers, then the task worker, then the download's ranges, to what the memory holds."""
194
+ def rest(workers: int, task_workers: int) -> int:
195
+ per_set = sum(
196
+ PER_WORKER_MB[svc] for svc in ALL_SERVICES if svc != "pii" or "pii" in self.modules
197
+ )
198
+ modules = sum(MODULE_MB.get(m, 0) for m in self.modules)
199
+ return FIXED_MB + modules + workers * per_set + task_workers * TASK_WORKER_MB
200
+
201
+ budget = self.memory_budget_mb
202
+ smallest_download = MIN_DOWNLOAD_RANGES * XET_RANGE_MB
203
+ workers = max(self.workers.values()) if self.workers else 1
204
+ while workers > 1 and rest(workers, self.task_workers) + smallest_download > budget:
205
+ workers -= 1
206
+ if rest(workers, self.task_workers) + smallest_download > budget:
207
+ self.task_workers = 1
208
+ for svc in HEAVY_SERVICES:
209
+ self.workers[svc] = min(self.workers[svc], workers)
210
+ # A quarter of what is left may go to a download, in whole ranges.
211
+ left = budget - rest(workers, self.task_workers)
212
+ self.download_ranges = max(MIN_DOWNLOAD_RANGES, min(MAX_DOWNLOAD_RANGES, left // 4 // XET_RANGE_MB))
213
+
214
+ @property
215
+ def max_connections(self) -> int:
216
+ """Postgres ``max_connections`` for them all, with headroom, in steps of 50."""
217
+ needed = sum(self.db_connections().values()) + CONNECTION_HEADROOM
218
+ return max(MIN_MAX_CONNECTIONS, math.ceil(needed / 50) * 50)
219
+
220
+ def to_env_dict(self) -> dict[str, str]:
221
+ """Flatten into a dict of ``FADEN_*`` env var → value."""
222
+ env: dict[str, str] = {
223
+ "POSTGRES_MAX_CONNECTIONS": str(self.max_connections),
224
+ # What deploy and upgrade tune with again.
225
+ "FADEN_TUNE_PROFILE": self.profile,
226
+ "FADEN_TUNE_RESOURCE_PCT": str(self.resource_pct),
227
+ # Sized by the memory (see ``_fit_memory``).
228
+ "FADEN_BACKEND_TASK_WORKERS": str(self.task_workers),
229
+ "FADEN_BACKEND_DOWNLOAD_RANGES": str(self.download_ranges),
230
+ }
231
+
232
+ for svc in ALL_SERVICES:
233
+ pfx = self._ENV_PREFIX[svc]
234
+ env[f"{pfx}WORKERS_COUNT"] = str(self.workers[svc])
235
+
236
+ for svc in DB_SERVICES:
237
+ pfx = self._ENV_PREFIX[svc]
238
+ env[f"{pfx}DB_POOL_SIZE"] = str(self.db_pool_size[svc])
239
+ env[f"{pfx}DB_MAX_OVERFLOW"] = str(self.db_max_overflow[svc])
240
+
241
+ for svc in RABBIT_SERVICES:
242
+ pfx = self._ENV_PREFIX[svc]
243
+ env[f"{pfx}RABBIT_POOL_SIZE"] = str(self.rabbit_pool_size[svc])
244
+ env[f"{pfx}RABBIT_CHANNEL_POOL_SIZE"] = str(
245
+ self.rabbit_channel_pool_size[svc],
246
+ )
247
+
248
+ return env
249
+
250
+
251
+ # ── Calculator ────────────────────────────────────────────────────
252
+
253
+
254
+ def calculate_tune_profile(
255
+ profile: str = "dev",
256
+ system: SystemInfo | None = None,
257
+ *,
258
+ resource_pct: float = DEFAULT_RESOURCE_PCT,
259
+ modules: frozenset[str] | set[str] = frozenset(),
260
+ ) -> TuneProfile:
261
+ """Calculate optimal scalability parameters for *profile*.
262
+
263
+ Parameters
264
+ ----------
265
+ profile
266
+ ``"dev"`` for conservative local defaults, ``"prod"`` for
267
+ production-grade sizing.
268
+ system
269
+ Pre-detected system info. If ``None``, :func:`detect_system`
270
+ is called automatically.
271
+ resource_pct
272
+ Fraction (0.0–1.0) of host CPU to allocate to the control
273
+ plane. Only affects the ``"prod"`` profile. Defaults to
274
+ :data:`DEFAULT_RESOURCE_PCT` (25 %).
275
+ modules
276
+ The optional modules switched on (``pii``, ``mcp``, ``skills``):
277
+ they take memory, and PII's workers are counted only with it on.
278
+
279
+ The CPU sets the number of workers, and the memory then cuts it to what
280
+ the host holds -- see ``TuneProfile._fit_memory``.
281
+ """
282
+ sys = system or detect_system()
283
+ tp = TuneProfile(profile=profile, system=sys, resource_pct=resource_pct, modules=frozenset(modules))
284
+
285
+ # Workers: dev keeps things light (1-2); prod scales within the CPU
286
+ # budget (resource_pct of the physical cores, e.g. 2 of 8 at 25 %).
287
+ workers = min(sys.physical_cores, 2) if profile == "dev" else tp.budget_cores
288
+ for svc in HEAVY_SERVICES:
289
+ tp.workers[svc] = max(1, workers)
290
+ for svc in LIGHT_SERVICES:
291
+ tp.workers[svc] = 1
292
+ tp.cpu_workers = max(1, workers)
293
+ tp._fit_memory()
294
+
295
+ # Pools: the same per worker process, whatever the number of workers.
296
+ for svc in DB_SERVICES:
297
+ tp.db_pool_size[svc] = DB_POOL_PER_WORKER
298
+ tp.db_max_overflow[svc] = DB_OVERFLOW_PER_WORKER.get(profile, DB_OVERFLOW_PER_WORKER["prod"])
299
+ for svc in RABBIT_SERVICES:
300
+ tp.rabbit_pool_size[svc] = RABBIT_POOL_PER_WORKER
301
+ tp.rabbit_channel_pool_size[svc] = RABBIT_CHANNELS_PER_WORKER
302
+
303
+ return tp