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,1392 @@
1
+ """``faden dev up`` — start the development servers.
2
+
3
+ Mirrors the logic of ``start-dev.ps1`` / ``start-dev.sh``:
4
+ 1. Start shared infrastructure (if not already running)
5
+ 2. Install/sync dependencies
6
+ 3. Run migrations
7
+ 4. Launch backend, taskiq worker, and frontend
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import platform
14
+ import signal
15
+ import socket
16
+ import time
17
+ import shlex
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import click
24
+ import psutil
25
+
26
+ from faden_cli.core.console import console, success, warning, error, info
27
+ from faden_cli.core.registry import DEV_ENDPOINTS, DEV_MODULE_ENDPOINTS, ModuleInfo
28
+ from faden_cli.core.settings import load_config
29
+ from faden_cli.core.workspace import (
30
+ find_service_dir,
31
+ resolve_shared_compose,
32
+ resolve_workspace,
33
+ )
34
+
35
+ from .dev_group import dev_group
36
+
37
+
38
+ def _find_workspace() -> Path:
39
+ """The workspace to start, remembering one we had to detect.
40
+
41
+ ``dev init`` used to be a precondition purely because it was the only
42
+ thing that wrote ``dev.workspace_dir``. Detection removes that: a
43
+ developer who cloned the repos themselves can run ``dev up`` directly,
44
+ from the workspace root or from inside any service.
45
+ """
46
+ return resolve_workspace(remember=True)
47
+
48
+
49
+ def _ensure_databases(backend_dir: Path) -> None:
50
+ """Create the per-service databases if they are absent.
51
+
52
+ Only ``dev init`` did this before, which is what made it a hard
53
+ precondition: without the databases the Alembic step below fails on a
54
+ checkout that was never initialised, and equally after
55
+ ``dev down --volumes`` wipes them. Both are recoverable, so recover.
56
+ """
57
+ from faden_cli.commands.dev.dev_init import _ensure_backend_role, _ensure_database
58
+ from faden_cli.core.registry import DATABASES
59
+
60
+ created = [name for name in DATABASES if _ensure_database(name, quiet=True)]
61
+ if created:
62
+ success(f"Created missing databases: {', '.join(created)}")
63
+ if backend_dir.exists():
64
+ _ensure_backend_role(backend_dir)
65
+
66
+
67
+ #: The Windows Terminal window these tabs go into.
68
+ #:
69
+ #: Named rather than numbered so a second ``dev up`` reuses the same window
70
+ #: instead of opening another, and so the tabs never land in a window the
71
+ #: operator is working in.
72
+ _WT_WINDOW_NAME = "faden-dev"
73
+
74
+
75
+ def _launch_terminal(title: str, working_dir: Path, command: str, headless: bool) -> bool:
76
+ """Start a dev process.
77
+
78
+ With a GUI (terminal emulator on PATH / a remote SSH session that has
79
+ a DISPLAY), spawn a terminal window. Otherwise fall back to a
80
+ background process (headless). Returns False if the process could
81
+ not be started at all.
82
+ """
83
+ system = platform.system()
84
+
85
+ if headless or not _can_open_terminal(system):
86
+ return _launch_background(working_dir, command, label=title.split("–")[0].strip().lower())
87
+
88
+ if system == "Windows":
89
+ # Resolve shell: pwsh (PS7) > powershell (PS5) > cmd
90
+ shell = _which("pwsh") or _which("powershell") or "cmd"
91
+ shell_name = os.path.basename(shell).lower()
92
+
93
+ wt = _which("wt")
94
+ if wt:
95
+ # ``-w`` is what makes these tabs rather than windows.
96
+ #
97
+ # ``wt new-tab`` on its own opens a *new window* every time it is
98
+ # invoked -- "new-tab" describes what it puts in the window, not
99
+ # where it puts it -- so starting four services gave four windows
100
+ # scattered across the desktop.
101
+ #
102
+ # Naming the window fixes that, and Terminal creates it on first
103
+ # use: "If no window exists with the given window-id, then a new
104
+ # window will be created with that id/name." So the backend opens
105
+ # the window and the worker, gateway and frontend land beside it.
106
+ #
107
+ # A name rather than ``0`` ("most recent window") on purpose: with
108
+ # ``0`` the tabs would land in whichever Terminal window the
109
+ # operator happened to touch last, which may be one they are
110
+ # working in.
111
+ subprocess.Popen(
112
+ [
113
+ wt, "-w", _WT_WINDOW_NAME, "new-tab",
114
+ "--title", title,
115
+ "--startingDirectory", str(working_dir),
116
+ shell, "-NoExit", "-Command", command,
117
+ ],
118
+ )
119
+ elif "powershell" in shell_name or "pwsh" in shell_name:
120
+ subprocess.Popen(
121
+ [shell, "-NoExit", "-Command", f"Set-Location '{working_dir}'; {command}"],
122
+ creationflags=subprocess.CREATE_NEW_CONSOLE,
123
+ )
124
+ else:
125
+ subprocess.Popen(
126
+ [shell, "/k", f"cd /d \"{working_dir}\" && {command}"],
127
+ creationflags=subprocess.CREATE_NEW_CONSOLE,
128
+ )
129
+ return True
130
+ elif system == "Darwin":
131
+ # macOS: use osascript to open Terminal.app
132
+ apple_script = (
133
+ f'tell application "Terminal" to do script '
134
+ f'"cd {working_dir} && {command}"'
135
+ )
136
+ subprocess.Popen(["osascript", "-e", apple_script])
137
+ return True
138
+
139
+ # Linux / other: try common terminal emulators
140
+ for term in ("gnome-terminal", "konsole", "xfce4-terminal", "xterm"):
141
+ if _which(term):
142
+ if term == "gnome-terminal":
143
+ subprocess.Popen(
144
+ [term, "--title", title, "--working-directory", str(working_dir), "--", "bash", "-c", command],
145
+ )
146
+ else:
147
+ subprocess.Popen(
148
+ [term, "-e", f"bash -c 'cd {working_dir} && {command}'"],
149
+ )
150
+ return True
151
+
152
+ # No terminal emulator — fall back to background mode.
153
+ return _launch_background(working_dir, command, label=title.split("–")[0].strip().lower())
154
+
155
+
156
+ def _can_open_terminal(system: str) -> bool:
157
+ """Heuristic: can we spawn an interactive terminal window?"""
158
+ if system in ("Windows", "Darwin"):
159
+ return True
160
+ if system == "Linux":
161
+ # A running SSH session without a DISPLAY has no usable display,
162
+ # so spawned terminal emulators would fail.
163
+ if not os.environ.get("DISPLAY"):
164
+ return False
165
+ return any(_which(t) for t in ("gnome-terminal", "konsole", "xfce4-terminal", "xterm"))
166
+ return True
167
+
168
+
169
+ def _launch_background(working_dir: Path, command: str, label: str | None = None) -> bool:
170
+ """Start a dev process detached (headless) and log its output."""
171
+ from faden_cli.core.workspace import dev_logs_dir, log_filename
172
+
173
+ log_dir = dev_logs_dir()
174
+ log_dir.mkdir(parents=True, exist_ok=True)
175
+ log_file = log_dir / log_filename(label or os.path.basename(str(working_dir)))
176
+ # Non-login shells (headless servers) don't source the profile, so
177
+ # ~./local/bin (uv, taskiq extras) and SDK locations may be missing
178
+ # from PATH. Prepend the usual suspects so the launched command
179
+ # resolves the same tools an interactive shell would.
180
+ is_windows = platform.system() == "Windows"
181
+ candidates = [
182
+ str(Path.home() / ".local" / "bin"),
183
+ str(Path.home() / ".cargo" / "bin"),
184
+ ]
185
+ if not is_windows:
186
+ candidates += ["/opt/homebrew/bin", "/usr/local/bin"]
187
+ extra_path = os.pathsep.join(filter(None, candidates))
188
+
189
+ try:
190
+ with open(log_file, "ab") as log:
191
+ if is_windows:
192
+ # Windows has no bash of its own, and the one Git ships cannot
193
+ # cd into a native path -- a quoted C:\... is not a POSIX path.
194
+ # Every headless service died on the script's first line with
195
+ # "No such file or directory" and left a 100-byte log saying
196
+ # so. Use the platform's own shell, and let Popen set the
197
+ # directory instead of writing a cd at all.
198
+ env = dict(os.environ)
199
+ env["PATH"] = extra_path + os.pathsep + env.get("PATH", "")
200
+ proc = subprocess.Popen(
201
+ command,
202
+ shell=True,
203
+ cwd=str(working_dir),
204
+ env=env,
205
+ stdout=log,
206
+ stderr=subprocess.STDOUT,
207
+ stdin=subprocess.DEVNULL,
208
+ # CREATE_NO_WINDOW, not DETACHED_PROCESS: both hide the
209
+ # console, but a detached process does not inherit the
210
+ # handles passed above, so every log file stayed empty --
211
+ # which defeats the whole point of headless mode.
212
+ creationflags=(
213
+ subprocess.CREATE_NEW_PROCESS_GROUP
214
+ | getattr(subprocess, "CREATE_NO_WINDOW", 0)
215
+ ),
216
+ )
217
+ else:
218
+ script = (
219
+ "set -e\n"
220
+ f"export PATH={shlex.quote(extra_path + os.pathsep)}${{PATH:-}}\n"
221
+ f"cd {shlex.quote(str(working_dir))}\n"
222
+ f"{command}\n"
223
+ )
224
+ proc = subprocess.Popen(
225
+ ["bash", "-c", script],
226
+ stdout=log,
227
+ stderr=subprocess.STDOUT,
228
+ stdin=subprocess.DEVNULL,
229
+ start_new_session=True,
230
+ )
231
+ except OSError as exc:
232
+ error(f"Could not start background process for {working_dir}: {exc}")
233
+ return False
234
+ info(f"{label or os.path.basename(str(working_dir))} (pid {proc.pid}) → {log_file}")
235
+ return True
236
+
237
+
238
+ def _which(name: str) -> str | None:
239
+ """Check if a tool is on PATH."""
240
+ return shutil.which(name)
241
+
242
+
243
+ def _ensure_backend_env(backend_dir: Path, workspace: Path) -> None:
244
+ """Create or repair ``backend/.env``.
245
+
246
+ Creates it with the dev defaults if missing. If it exists, the file
247
+ is self-healed in place (all other lines preserved):
248
+
249
+ * stale values for any known dev key (e.g. the per-service RabbitMQ
250
+ user/pass from ``RABBITMQ_BACKEND_PASS`` — old files may carry the
251
+ hard-coded ``guest/guest`` default that no longer exists on the
252
+ broker) are rewritten (the bind address is ``_apply_bind_hosts``'s);
253
+ * dev keys added in newer CLI versions that the file predates
254
+ (``COOKIE_SECURE``, ``GATEWAY_URL``, …) are appended.
255
+ """
256
+ env_path = backend_dir / ".env"
257
+ if not backend_dir.exists():
258
+ return
259
+ from faden_cli.core.env_gen import write_env_file
260
+ from faden_cli.core.registry import backend_dev_env_for
261
+
262
+ shared_env_path = _shared_env_path(workspace)
263
+ if not shared_env_path:
264
+ return
265
+ desired = backend_dev_env_for(shared_env_path)
266
+
267
+ if not env_path.exists():
268
+ write_env_file(env_path, desired)
269
+ info(f"Generated backend .env at {env_path}")
270
+ return
271
+
272
+ # Keys the CLI owns outright: a stale value gets rewritten in place.
273
+ # (DB credentials are intentionally NOT here — a user-edited DB
274
+ # pass in the file must survive across `dev up` runs. The bind address,
275
+ # FADEN_BACKEND_HOST, is set on every run by _apply_bind_hosts.)
276
+ rewrite = {
277
+ k: v
278
+ for k, v in desired.items()
279
+ if k in ("FADEN_BACKEND_RABBIT_USER", "FADEN_BACKEND_RABBIT_PASS")
280
+ }
281
+ # Keys added in newer CLI versions: appended if the file predates them.
282
+ append = {
283
+ k: v
284
+ for k, v in desired.items()
285
+ if k
286
+ in (
287
+ "FADEN_BACKEND_COOKIE_SECURE",
288
+ "FADEN_BACKEND_GATEWAY_URL",
289
+ "FADEN_BACKEND_ENVIRONMENT",
290
+ # The shared Postgres login for the gateway's tables: a fresh
291
+ # database has no llm_user any more (shared/initdb).
292
+ "FADEN_BACKEND_LLM_GRAPH_DB_USER",
293
+ "FADEN_BACKEND_LLM_GRAPH_DB_PASS",
294
+ )
295
+ }
296
+
297
+ lines = env_path.read_text(encoding="utf-8").splitlines()
298
+ current: dict[str, str] = {}
299
+ for line in lines:
300
+ s = line.strip()
301
+ if s and not s.startswith("#") and "=" in s:
302
+ k, _, v = s.partition("=")
303
+ current[k.strip()] = v.strip()
304
+
305
+ loopback = _loopback_fixes(current)
306
+ rewrite.update(loopback)
307
+ stale = [k for k, v in rewrite.items() if current.get(k) != v]
308
+ missing = [
309
+ k for k in rewrite if k not in current and k not in loopback
310
+ ] + [k for k in append if k not in current]
311
+ if not stale and not missing:
312
+ return
313
+
314
+ fixed: list[str] = []
315
+ for line in lines:
316
+ s = line.strip()
317
+ k = s.split("=", 1)[0].strip() if s and not s.startswith("#") and "=" in s else None
318
+ if k in stale:
319
+ fixed.append(f"{k}={rewrite[k]}") # rewrite stale value in place
320
+ else:
321
+ fixed.append(line)
322
+ if missing:
323
+ if fixed and fixed[-1].strip():
324
+ fixed.append("")
325
+ fixed.append("# ── faden dev up: dev defaults ──")
326
+ known = {**rewrite, **append}
327
+ fixed.extend(f"{k}={known[k]}" for k in missing)
328
+ env_path.write_text("\n".join(fixed) + "\n", encoding="utf-8")
329
+ info("Backend .env healed to match the current dev defaults.")
330
+
331
+
332
+ def _loopback_fixes(current: dict[str, str]) -> dict[str, str]:
333
+ """Infra host keys still set to "localhost", mapped to 127.0.0.1.
334
+
335
+ Files written by older CLIs say "localhost", which costs a 21 s stall on
336
+ every new connection on Windows (see ``INFRA_LOOPBACK``). Only that exact
337
+ value is rewritten: a host someone pointed elsewhere is theirs.
338
+ """
339
+ from faden_cli.core.registry import INFRA_HOST_KEYS, INFRA_LOOPBACK
340
+
341
+ return {
342
+ k: INFRA_LOOPBACK
343
+ for k in INFRA_HOST_KEYS
344
+ if current.get(k, "").strip().lower() == "localhost"
345
+ }
346
+
347
+
348
+ def _shared_env_path(workspace: Path) -> Path | None:
349
+ """Locate the shared (infra) ``.env`` for the dev workspace."""
350
+ for candidate in (
351
+ workspace / "shared" / ".env",
352
+ workspace / "fadenstack" / "shared" / ".env",
353
+ ):
354
+ if candidate.exists():
355
+ return candidate
356
+ return None
357
+
358
+
359
+ def _ensure_shared_env(workspace: Path) -> Path | None:
360
+ """Create or repair the shared (infra) ``.env``.
361
+
362
+ ``dev down`` deletes the generated env files as part of a full reset,
363
+ so a plain ``dev up`` must regenerate them before infra starts:
364
+
365
+ * shared ``.env`` missing → write fresh dev credentials, then
366
+ regenerate the RabbitMQ ``definitions.json`` from it (mirrors
367
+ ``dev init`` step 2a + 2c; the RMQ broker comes up with the
368
+ matching per-service users).
369
+ * shared ``.env`` present → untouched (its DB/RMQ credentials must
370
+ survive so the volumes they match are still usable; when the
371
+ volumes were wiped, fresh containers self-seed from the env).
372
+ The RabbitMQ files are written from it when the checkout has none:
373
+ they are generated, not tracked, and the compose file mounts them.
374
+
375
+ Returns the shared env path (existing or newly written), or ``None``
376
+ if the shared compose directory cannot be found.
377
+ """
378
+ from faden_cli.core.env_gen import dev_env_vars, write_env_file
379
+ from faden_cli.core.workspace import resolve_shared_compose
380
+
381
+ compose_file = resolve_shared_compose(workspace)
382
+ if not compose_file:
383
+ return None
384
+ shared_dir = compose_file.parent
385
+ env_path = shared_dir / ".env"
386
+ if env_path.exists():
387
+ heal_dev_env(env_path)
388
+ rmq = shared_dir / "rabbitmq"
389
+ if not ((rmq / "definitions.json").is_file() and (rmq / "10-faden.conf").is_file()):
390
+ # Missing, Docker would mount a directory in their place.
391
+ from faden_cli.commands.dev.dev_init import _resync_rmq_credentials
392
+
393
+ _resync_rmq_credentials(shared_dir, skip_infra=True)
394
+ return env_path
395
+
396
+ info(f"Shared .env not found — generating fresh dev credentials at {env_path}")
397
+ write_env_file(env_path, dev_env_vars())
398
+ try:
399
+ from faden_cli.commands.dev.dev_init import _resync_rmq_credentials
400
+
401
+ _resync_rmq_credentials(shared_dir, skip_infra=False)
402
+ except Exception as exc: # noqa: BLE001 — resync is best-effort
403
+ warning(f"RMQ definitions resync skipped ({exc}); broker users may need a manual restart.")
404
+ return env_path
405
+
406
+
407
+ def heal_dev_env(env_path: Path) -> None:
408
+ """Give the shared development ``.env`` what the compose file requires and it lacks.
409
+
410
+ The compose file has no fallback for its secrets any more and stops on any
411
+ missing, whichever services start; a development ``.env`` written before
412
+ gets the development values (never a change to one it has).
413
+ """
414
+ from faden_cli.core.env_gen import fill_dev_env
415
+
416
+ added = fill_dev_env(env_path)
417
+ if added:
418
+ info(f"Shared .env: added the development values the compose file requires ({len(added)}).")
419
+
420
+
421
+ def _ensure_gateway_env(api_dir: Path, workspace: Path) -> None:
422
+ """Create ``gateway/.env`` for the host-running gateway.
423
+
424
+ The gateway is the OpenAI-compatible ``/v1`` edge; in dev it runs as a
425
+ host process (port 8001) and must point at the shared 127.0.0.1
426
+ publish ports plus the host-local DB broker credentials from the
427
+ shared ``.env``. Missing credential keys are preserved on re-runs
428
+ (only absent keys are filled in) so a hand-edited file survives.
429
+ """
430
+ if not api_dir.exists():
431
+ return
432
+ from faden_cli.core.env_gen import write_env_file
433
+ from faden_cli.core.registry import gateway_dev_env_for
434
+
435
+ shared_env_path = _shared_env_path(workspace)
436
+ if not shared_env_path:
437
+ warning("Shared .env not found — gateway will start with default credentials.")
438
+ return
439
+ desired = gateway_dev_env_for(shared_env_path)
440
+ env_path = api_dir / ".env"
441
+
442
+ if not env_path.exists():
443
+ write_env_file(env_path, desired)
444
+ info(f"Generated gateway .env at {env_path}")
445
+ return
446
+
447
+ existing: dict[str, str] = {}
448
+ for line in env_path.read_text(encoding="utf-8").splitlines():
449
+ s = line.strip()
450
+ if s and not s.startswith("#") and "=" in s:
451
+ k, _, v = s.partition("=")
452
+ existing[k.strip()] = v.strip()
453
+ missing = {k: v for k, v in desired.items() if k not in existing}
454
+ loopback = _loopback_fixes(existing)
455
+ if loopback:
456
+ lines = []
457
+ for line in env_path.read_text(encoding="utf-8").splitlines():
458
+ s = line.strip()
459
+ k = s.split("=", 1)[0].strip() if s and not s.startswith("#") and "=" in s else None
460
+ lines.append(f"{k}={loopback[k]}" if k in loopback else line)
461
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
462
+ info("Gateway .env: infra hosts moved from localhost to 127.0.0.1.")
463
+ if not missing:
464
+ return
465
+ with env_path.open("a", encoding="utf-8") as fh:
466
+ fh.write("# ── faden dev up: gateway dev defaults ──\n")
467
+ for k, v in missing.items():
468
+ fh.write(f"{k}={v}\n")
469
+ info("Gateway .env completed with missing dev defaults.")
470
+
471
+
472
+ def _apply_bind_hosts(backend_dir: Path, api_dir: Path, *, lan: bool) -> str:
473
+ """Where the backend and the gateway listen this run; the address, written into their ``.env``.
474
+
475
+ This machine only, unless *lan*: dev mode lets anyone sign in as the
476
+ administrator (dev-login, admin/admin), and on every interface that was
477
+ anyone on the network. Set on every run, so ``--lan`` lasts one run.
478
+ """
479
+ from faden_cli.core.registry import DEV_BIND_HOST, DEV_LAN_HOST
480
+
481
+ host = DEV_LAN_HOST if lan else DEV_BIND_HOST
482
+ for service_dir, key in ((backend_dir, "FADEN_BACKEND_HOST"), (api_dir, "FADEN_GATEWAY_HOST")):
483
+ env_path = service_dir / ".env"
484
+ if env_path.exists():
485
+ _apply_env(env_path, {key: host}, header="faden dev up: where it listens")
486
+ return host
487
+
488
+
489
+ def _frontend_command(*, lan: bool) -> str:
490
+ """How the frontend dev server starts: on this machine only, or with ``--lan`` on every interface."""
491
+ from faden_cli.core.registry import DEV_LAN_HOST
492
+
493
+ return f"npm run dev -- --host {DEV_LAN_HOST}" if lan else "npm run dev"
494
+
495
+
496
+ def _apply_env(env_path: Path, values: dict[str, str], *, header: str) -> bool:
497
+ """Set *values* in the ``.env`` at *env_path*, keeping every other line.
498
+
499
+ A key already in the file is rewritten where it stands; a missing one is
500
+ appended under *header*. Returns whether the file changed.
501
+ """
502
+ before = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
503
+ out: list[str] = []
504
+ seen: set[str] = set()
505
+ for line in before.splitlines():
506
+ s = line.strip()
507
+ key = s.split("=", 1)[0].strip() if s and not s.startswith("#") and "=" in s else None
508
+ if key in values:
509
+ seen.add(key)
510
+ out.append(f"{key}={values[key]}")
511
+ else:
512
+ out.append(line)
513
+ missing = [k for k in values if k not in seen]
514
+ if missing:
515
+ if out and out[-1].strip():
516
+ out.append("")
517
+ out.append(f"# ── {header} ──")
518
+ out.extend(f"{k}={values[k]}" for k in missing)
519
+ after = "\n".join(out) + "\n"
520
+ if after == before:
521
+ return False
522
+ env_path.write_text(after, encoding="utf-8")
523
+ return True
524
+
525
+
526
+ def _ensure_module_envs(
527
+ workspace: Path, backend_dir: Path, api_dir: Path, running: list[ModuleInfo],
528
+ ) -> None:
529
+ """Point each module, and the gateway and backend, at the modules that run.
530
+
531
+ The switches in the gateway and backend ``.env`` are owned by ``dev up``:
532
+ a module that is not started this time is switched off, not left pointing
533
+ at a port nothing listens on.
534
+ """
535
+ from faden_cli.core.dev_modules import caller_env, module_env
536
+ from faden_cli.core.registry import _read_env_values
537
+
538
+ shared_path = _shared_env_path(workspace)
539
+ shared = _read_env_values(shared_path) if shared_path else {}
540
+ header = "faden dev up: modules"
541
+ if api_dir.exists() and _apply_env(api_dir / ".env", caller_env("GATEWAY", shared=shared, running=running), header=header):
542
+ info("Gateway .env: module switches updated.")
543
+ if backend_dir.exists() and _apply_env(
544
+ backend_dir / ".env", caller_env("BACKEND", shared=shared, running=running), header=header,
545
+ ):
546
+ info("Backend .env: module switches updated.")
547
+ for module in running:
548
+ module_dir = find_service_dir(workspace, module.dev_dir)
549
+ if module_dir.exists():
550
+ _apply_env(module_dir / ".env", module_env(module, shared=shared, running=running), header=header)
551
+
552
+
553
+ def _install_module_deps(module_dir: Path, module: ModuleInfo) -> None:
554
+ """Sync a module's dependencies, and install what its lock cannot carry.
555
+
556
+ ``--inexact``: an exact sync removes what the lock does not list, and the
557
+ spaCy model PII needs is exactly that -- it would be uninstalled and
558
+ downloaded again (~560 MB) on every ``dev up``.
559
+ """
560
+ label = _module_label(module)
561
+ env = _own_environment()
562
+ console.print(f"[cyan]Installing {label} dependencies (uv sync)…[/cyan]")
563
+ result = subprocess.run(["uv", "sync", "--locked", "--inexact"], cwd=str(module_dir), env=env)
564
+ if result.returncode != 0:
565
+ warning("uv sync --locked failed, retrying without --locked…")
566
+ result = subprocess.run(["uv", "sync", "--inexact"], cwd=str(module_dir), env=env)
567
+ if result.returncode != 0:
568
+ error(f"{label}: uv sync failed.")
569
+ return
570
+ for import_name, wheel in module.extra_wheels:
571
+ probe = subprocess.run(
572
+ ["uv", "run", "--no-sync", "python", "-c", f"import {import_name}"],
573
+ cwd=str(module_dir), capture_output=True, env=env,
574
+ )
575
+ if probe.returncode == 0:
576
+ continue
577
+ console.print(f"[cyan]Installing {import_name} for {label} (one-time download)…[/cyan]")
578
+ if subprocess.run(["uv", "pip", "install", wheel], cwd=str(module_dir), env=env).returncode != 0:
579
+ error(f"{label}: could not install {import_name}; it will not start without it.")
580
+ success(f"{label} dependencies installed.")
581
+
582
+
583
+ def _own_environment() -> dict[str, str]:
584
+ """This environment, less the virtualenv ``dev up`` itself runs in.
585
+
586
+ Run as ``uv run faden``, the CLI's own ``.venv`` is ``VIRTUAL_ENV``.
587
+ ``uv sync`` and ``uv run`` ignore it inside a project, but ``uv pip``
588
+ honours it before the project's ``.venv``: PII's spaCy model was
589
+ installed into the CLI, and PII could not load it.
590
+ """
591
+ return {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
592
+
593
+
594
+ def _migrate_module(module_dir: Path, module: ModuleInfo) -> None:
595
+ """Bring a module's database up to date (its ``.env`` has the credentials)."""
596
+ if not module.database or not (module_dir / "alembic.ini").exists():
597
+ return
598
+ result = subprocess.run(
599
+ ["uv", "run", "alembic", "upgrade", "head"], cwd=str(module_dir), env=_own_environment(),
600
+ )
601
+ if result.returncode != 0:
602
+ warning(f"{_module_label(module)}: Alembic migration exited with non-zero code.")
603
+ else:
604
+ success(f"{_module_label(module)} migrations up to date.")
605
+
606
+
607
+ def _module_label(module: ModuleInfo) -> str:
608
+ return {"pii": "PII", "mcp": "MCP"}.get(module.name, module.name.capitalize())
609
+
610
+
611
+ def _module_present(workspace: Path, module: ModuleInfo) -> bool:
612
+ """Whether the module's code is in this workspace; says so when it is not."""
613
+ if find_service_dir(workspace, module.dev_dir).exists():
614
+ return True
615
+ warning(f"{_module_label(module)}: {module.dev_dir} is not in this workspace — not started.")
616
+ return False
617
+
618
+
619
+ #: Ports the dev services bind, for the check after stopping them. The ports
620
+ #: of the modules started this time are added by ``dev up``.
621
+ _DEV_PORTS = {8000: "Backend", 8001: "API gateway", 5173: "Frontend"}
622
+
623
+
624
+ def _own_process_chain() -> set[int]:
625
+ """This process and every ancestor of it.
626
+
627
+ The reclaim matches on the workspace path, and so does the command line of
628
+ whatever launched us -- ``uv run -m faden_cli`` from a shell inside the
629
+ workspace. Without this the reclaim killed its own parent.
630
+ """
631
+ chain: set[int] = set()
632
+ pid = os.getpid()
633
+ for _ in range(12): # a guard against a cycle, not a real depth limit
634
+ if pid <= 0 or pid in chain:
635
+ break
636
+ chain.add(pid)
637
+ parent = _parent_pid(pid)
638
+ if parent is None:
639
+ break
640
+ pid = parent
641
+ return chain
642
+
643
+
644
+ def _parent_pid(pid: int) -> int | None:
645
+ """The parent of *pid*, or ``None`` when it cannot be determined."""
646
+ if platform.system() == "Windows":
647
+ try:
648
+ result = subprocess.run(
649
+ ["powershell", "-NoProfile", "-Command",
650
+ f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}')"
651
+ ".ParentProcessId"],
652
+ capture_output=True, text=True, timeout=20,
653
+ )
654
+ except Exception: # noqa: BLE001 - best effort
655
+ return None
656
+ text = result.stdout.strip()
657
+ return int(text) if text.isdigit() else None
658
+
659
+ try:
660
+ # /proc/<pid>/stat: pid (comm) state ppid ... -- comm can contain
661
+ # spaces and parentheses, so split after the last ')'.
662
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
663
+ return int(stat[stat.rindex(")") + 1:].split()[1])
664
+ except (OSError, ValueError, IndexError):
665
+ return None
666
+
667
+
668
+ def _stop_workspace_services(workspace: Path) -> list[int]:
669
+ """Stop every dev service already running out of *workspace*.
670
+
671
+ Scoped by the workspace path rather than by process name: the point is to
672
+ reclaim *this* checkout's services, not to kill anybody's unrelated
673
+ Python. Every one of them -- backend, worker, gateway, frontend -- runs
674
+ from an interpreter or a node_modules under the workspace, so the path is
675
+ in the command line.
676
+
677
+ Returns the pids stopped, for the caller to report.
678
+ """
679
+ if platform.system() != "Windows":
680
+ return _stop_workspace_services_posix(workspace)
681
+
682
+ # Get-Process has no CommandLine on PowerShell 5.1, so a Where-Object on
683
+ # it matches nothing and the stop silently does nothing. CIM has it on
684
+ # every version.
685
+ script = (
686
+ "$ws = '" + str(workspace).replace("'", "''") + "';"
687
+ "Get-CimInstance Win32_Process |"
688
+ " Where-Object { $_.CommandLine -and $_.CommandLine -like \"*$ws*\" -and"
689
+ " ($_.Name -eq 'python.exe' -or $_.Name -eq 'node.exe' -or"
690
+ " $_.Name -eq 'taskiq.exe' -or $_.Name -eq 'uv.exe') } |"
691
+ " ForEach-Object { $_.ProcessId }"
692
+ )
693
+ try:
694
+ found = subprocess.run(
695
+ ["powershell", "-NoProfile", "-Command", script],
696
+ capture_output=True, text=True, timeout=60,
697
+ )
698
+ except Exception: # noqa: BLE001 - reclaiming is best effort
699
+ return []
700
+
701
+ pids = [int(line) for line in found.stdout.split() if line.strip().isdigit()]
702
+ # Spare this command and everything that launched it.
703
+ #
704
+ # Sparing only our own pid was not enough: ``dev up`` runs as
705
+ # ``uv run -m faden_cli``, so ``uv.exe`` is our parent and its command line
706
+ # names the workspace just as a service's does. Killing it took the whole
707
+ # terminal down, mid-reclaim, leaving nothing started and no explanation.
708
+ own = _own_process_chain()
709
+ pids = [pid for pid in pids if pid not in own]
710
+ for pid in pids:
711
+ subprocess.run(
712
+ ["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=30
713
+ )
714
+ return pids
715
+
716
+
717
+ def _stop_workspace_services_posix(workspace: Path) -> list[int]:
718
+ """The same, through /proc-backed pgrep."""
719
+ try:
720
+ found = subprocess.run(
721
+ ["pgrep", "-f", str(workspace)], capture_output=True, text=True, timeout=30
722
+ )
723
+ except Exception: # noqa: BLE001 - pgrep is absent on some images
724
+ return []
725
+
726
+ own = _own_process_chain()
727
+ pids = [
728
+ int(line)
729
+ for line in found.stdout.split()
730
+ if line.strip().isdigit() and int(line) not in own
731
+ ]
732
+ for pid in pids:
733
+ try:
734
+ os.kill(pid, signal.SIGTERM)
735
+ except OSError:
736
+ continue
737
+ return pids
738
+
739
+
740
+ #: How ``dev up`` invokes each service. Matched literally, so an ad-hoc shell
741
+ #: that merely mentions a module name is not mistaken for a running service.
742
+ _SERVICE_INVOCATIONS = (
743
+ "uv run -m faden_backend",
744
+ "uv run -m faden_gateway",
745
+ "uv run taskiq worker",
746
+ "npm run dev",
747
+ "uv run -m faden_pii",
748
+ "uv run -m faden_mcp",
749
+ "uv run -m faden_skills",
750
+ )
751
+
752
+
753
+ def _is_within(path: Path, root: Path) -> bool:
754
+ """Whether *path* is *root* or sits under it."""
755
+ try:
756
+ path.resolve().relative_to(root.resolve())
757
+ except (ValueError, OSError):
758
+ return False
759
+ return True
760
+
761
+
762
+ def _stop_service_hosts(workspace: Path) -> list[int]:
763
+ """Stop the terminals hosting this workspace's services.
764
+
765
+ ``_stop_workspace_services`` matches the workspace path in a command
766
+ line. The service processes satisfy that; the shells hosting them do
767
+ not. A tab runs ``pwsh -NoExit -Command uv run -m faden_backend``,
768
+ which names the module and no path at all, so the leaf process was
769
+ killed and its host survived.
770
+
771
+ That is what "the backend serves stale code" actually was. The host had
772
+ inherited the listening socket, so the port stayed bound after its child
773
+ died; the replacement could not bind, exited without saying anything,
774
+ and the old code kept answering. Observed here as seven LISTEN entries
775
+ on port 8000, every one owned by a pid that no longer existed.
776
+
777
+ Matched on the exact invocation *and* a working directory inside the
778
+ workspace, so a second checkout running the same module is left alone.
779
+ """
780
+ own = _own_process_chain()
781
+ stopped: list[int] = []
782
+ for proc in psutil.process_iter(["cmdline"]):
783
+ if proc.pid in own:
784
+ continue
785
+ try:
786
+ cmdline = " ".join(proc.info.get("cmdline") or [])
787
+ if not any(form in cmdline for form in _SERVICE_INVOCATIONS):
788
+ continue
789
+ inside = str(workspace) in cmdline or _is_within(Path(proc.cwd()), workspace)
790
+ except (psutil.Error, OSError):
791
+ continue
792
+ if not inside:
793
+ continue
794
+ try:
795
+ proc.kill()
796
+ stopped.append(proc.pid)
797
+ except psutil.Error:
798
+ continue
799
+ return stopped
800
+
801
+
802
+ def _ports_still_held() -> dict[int, str]:
803
+ """Which dev ports are still listening, and what they are for.
804
+
805
+ Checked after stopping, because "the port is free" is the only thing that
806
+ actually predicts whether the service about to start will be the one
807
+ serving. A process we did not recognise -- started by hand, or from
808
+ another checkout -- holds the port just as well.
809
+ """
810
+ held: dict[int, str] = {}
811
+ for port, name in _DEV_PORTS.items():
812
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
813
+ probe.settimeout(0.5)
814
+ if probe.connect_ex(("127.0.0.1", port)) == 0:
815
+ held[port] = name
816
+ return held
817
+
818
+
819
+ #: How multiprocessing re-execs a worker. The whole command line, on every
820
+ #: platform, is this import plus a handle -- no module name, no package, no
821
+ #: path to anything that would say which service it belongs to.
822
+ _MULTIPROCESSING_WORKER = "from multiprocessing.spawn import spawn_main"
823
+
824
+
825
+ def _stop_orphaned_workers() -> list[int]:
826
+ """Stop multiprocessing workers whose supervisor is gone.
827
+
828
+ uvicorn and taskiq spawn their workers through ``multiprocessing``. Stop
829
+ the supervisor and those children survive, still holding the listening
830
+ socket they inherited -- and then nothing can find them. The system
831
+ connection table still attributes the socket to the supervisor, which no
832
+ longer exists, and no live process reports holding it either. The port
833
+ stays bound, and keeps serving the old build, while every tool that
834
+ looks says nobody owns it.
835
+
836
+ That is what "the backend is serving stale code" was. Twelve of these
837
+ were answering on port 8000 with every parent long dead, and they came
838
+ back on each restart because each restart made more.
839
+
840
+ Matched on that command line *and* a parent that no longer exists: the
841
+ workers of a running service have a living supervisor and are left alone.
842
+ """
843
+ own = _own_process_chain()
844
+ stopped: list[int] = []
845
+ for proc in psutil.process_iter(["cmdline", "ppid"]):
846
+ if proc.pid in own:
847
+ continue
848
+ try:
849
+ cmdline = " ".join(proc.info.get("cmdline") or [])
850
+ if _MULTIPROCESSING_WORKER not in cmdline:
851
+ continue
852
+ parent = proc.info.get("ppid")
853
+ if parent and psutil.pid_exists(parent):
854
+ continue # its supervisor is alive; not ours to reap
855
+ proc.kill()
856
+ except (psutil.Error, OSError):
857
+ continue
858
+ stopped.append(proc.pid)
859
+ return stopped
860
+
861
+
862
+ def _stop_port_holders(ports: dict[int, str]) -> list[int]:
863
+ """Stop whatever still holds a dev port, whatever it looks like.
864
+
865
+ Recognising a service by its name or path cannot be made to work.
866
+ uvicorn and taskiq spawn their workers through ``multiprocessing``,
867
+ which re-execs as ``python -c "from multiprocessing.spawn import
868
+ spawn_main; ..."``: no module name, no workspace path, and on this
869
+ machine not even the workspace's own interpreter -- the system one.
870
+ Twelve such orphans were found serving port 8000 with every parent long
871
+ dead, answering requests from another host, invisible to every filter
872
+ written to look for them.
873
+
874
+ So stop matching on what a process looks like. What matters is that it
875
+ is sitting on the port the service about to start needs. Its own process
876
+ chain is spared, as always.
877
+ """
878
+ own = _own_process_chain()
879
+ stopped: list[int] = []
880
+ try:
881
+ connections = psutil.net_connections(kind="inet")
882
+ except (psutil.Error, OSError, PermissionError):
883
+ return stopped
884
+
885
+ holders: dict[int, set[int]] = {}
886
+ for conn in connections:
887
+ if conn.status != psutil.CONN_LISTEN or not conn.pid or not conn.laddr:
888
+ continue
889
+ if conn.laddr.port in ports:
890
+ holders.setdefault(conn.laddr.port, set()).add(conn.pid)
891
+
892
+ for port, pids in sorted(holders.items()):
893
+ for pid in sorted(pids):
894
+ if pid in own or pid <= 4: # never the idle/system pids
895
+ continue
896
+ try:
897
+ proc = psutil.Process(pid)
898
+ name = proc.name()
899
+ except psutil.Error:
900
+ continue
901
+ for victim in _supervisor_tree(proc, own):
902
+ try:
903
+ victim.kill()
904
+ stopped.append(victim.pid)
905
+ except psutil.Error:
906
+ continue
907
+ warning(f"Stopped {name} (pid {pid}) still holding port {port} ({ports[port]}).")
908
+ return stopped
909
+
910
+
911
+ #: Processes that exist to run another process. Walked through when looking
912
+ #: for the supervisor above a port holder.
913
+ _SUPERVISOR_NAMES = frozenset({
914
+ "python.exe", "python", "python3", "uv.exe", "uv", "cmd.exe",
915
+ "node.exe", "node", "taskiq.exe", "npm.cmd", "pwsh.exe", "powershell.exe",
916
+ "sh", "bash",
917
+ })
918
+
919
+
920
+ def _supervisor_tree(proc: psutil.Process, own: set[int]) -> list[psutil.Process]:
921
+ """*proc*, its supervisor, and everything under that -- children first.
922
+
923
+ Killing the process that holds the port is not enough on its own. The
924
+ backend runs under a reload supervisor, so removing the worker just makes
925
+ the parent start another one, on the same port, and the reclaim reports
926
+ failure against a pid that did not exist when it looked. Walk up to the
927
+ top of the chain that exists only to run this service, then take the
928
+ whole tree.
929
+
930
+ The walk stops at anything outside that set, and at our own chain, so it
931
+ never climbs out into the operator's session.
932
+ """
933
+ top = proc
934
+ try:
935
+ for parent in proc.parents():
936
+ if parent.pid in own or parent.pid <= 4:
937
+ break
938
+ if parent.name().lower() not in _SUPERVISOR_NAMES:
939
+ break
940
+ top = parent
941
+ except psutil.Error:
942
+ pass
943
+
944
+ try:
945
+ # Children first: a supervisor that outlives its workers restarts them.
946
+ tree = [*reversed(top.children(recursive=True)), top]
947
+ except psutil.Error:
948
+ tree = [top]
949
+ return [p for p in tree if p.pid not in own and p.pid > 4]
950
+
951
+
952
+ def _reclaim_workspace(workspace: Path) -> None:
953
+ """Stop this workspace's services and say what is still in the way."""
954
+ stopped = _stop_workspace_services(workspace)
955
+ # Then the shells hosting them, which keep the listening socket alive
956
+ # after their child is gone.
957
+ stopped += _stop_service_hosts(workspace)
958
+ # And the workers those hosts left behind, which hold the port while
959
+ # appearing to belong to nobody.
960
+ stopped += _stop_orphaned_workers()
961
+ if stopped:
962
+ success(f"Stopped {len(stopped)} process(es) from a previous run.")
963
+
964
+ # Sockets linger briefly after the process holding them dies.
965
+ for _ in range(10):
966
+ held = _ports_still_held()
967
+ if not held:
968
+ return
969
+ time.sleep(0.5)
970
+
971
+ # Still held: stop whatever is on the port, since by now it is not a
972
+ # socket closing but a process that no filter above recognised.
973
+ if _stop_port_holders(held):
974
+ for _ in range(10):
975
+ held = _ports_still_held()
976
+ if not held:
977
+ return
978
+ time.sleep(0.5)
979
+
980
+ for port, name in held.items():
981
+ warning(
982
+ f"Port {port} ({name}) is still in use and could not be stopped. "
983
+ f"That process will keep serving, and the one started below will "
984
+ f"exit quietly -- so what answers on {port} will not be this build."
985
+ )
986
+
987
+
988
+ def _stop_old_workers() -> None:
989
+ """Kill any stale taskiq worker processes."""
990
+ if platform.system() != "Windows":
991
+ # On Unix, use pkill
992
+ subprocess.run(["pkill", "-f", "taskiq worker"], capture_output=True)
993
+ return
994
+
995
+ # Windows: use taskkill via pattern
996
+ try:
997
+ result = subprocess.run(
998
+ ["powershell", "-Command",
999
+ "Get-Process python*, uv* -ErrorAction SilentlyContinue | "
1000
+ "Where-Object { $_.CommandLine -like '*taskiq*worker*' } | "
1001
+ "Stop-Process -Force -ErrorAction SilentlyContinue"],
1002
+ capture_output=True,
1003
+ )
1004
+ except Exception:
1005
+ pass
1006
+
1007
+
1008
+ @dev_group.command("up")
1009
+ @click.option("--backend-only", is_flag=True, help="Start only the backend.")
1010
+ @click.option("--frontend-only", is_flag=True, help="Start only the frontend.")
1011
+ @click.option("--skip-infra", is_flag=True, help="Skip shared infrastructure check.")
1012
+ @click.option("--skip-deps", is_flag=True, help="Skip dependency installation.")
1013
+ @click.option("--skip-migrations", is_flag=True, help="Skip Alembic migrations.")
1014
+ @click.option(
1015
+ "--headless",
1016
+ is_flag=True,
1017
+ help="Run services as background processes with log files (for servers without a GUI).",
1018
+ )
1019
+ @click.option(
1020
+ "--modules",
1021
+ default=None,
1022
+ metavar="LIST",
1023
+ help=(
1024
+ "Optional modules to run beside the backend, comma-separated "
1025
+ "(pii, mcp, skills), or 'none'. Default: the ones switched on with "
1026
+ "`faden module enable`."
1027
+ ),
1028
+ )
1029
+ @click.option(
1030
+ "--local-node",
1031
+ is_flag=True,
1032
+ help="Provision faden_agent locally or over SSH before launching dev services.",
1033
+ )
1034
+ @click.option(
1035
+ "--local-node-host",
1036
+ default="",
1037
+ help="SSH host for node-agent provisioning (example: ubuntu@10.0.0.12).",
1038
+ )
1039
+ @click.option(
1040
+ "--local-node-workdir",
1041
+ default="",
1042
+ help="Install directory for node-agent repo on target host.",
1043
+ )
1044
+ @click.option(
1045
+ "--local-node-branch",
1046
+ default="",
1047
+ help="Git branch for faden-node-agent (default: config dev.branch).",
1048
+ )
1049
+ @click.option(
1050
+ "--local-node-backend-url",
1051
+ default="http://127.0.0.1:8000",
1052
+ show_default=True,
1053
+ help="Backend URL written to node-agent environment.",
1054
+ )
1055
+ @click.option(
1056
+ "--local-node-advertise-host",
1057
+ default="",
1058
+ help="Host/IP that node agent advertises for runtime endpoints.",
1059
+ )
1060
+ @click.option(
1061
+ "--local-node-enrollment-token",
1062
+ default="",
1063
+ help="Optional one-time enrollment token for initial node onboarding.",
1064
+ )
1065
+ @click.option(
1066
+ "--local-node-sudo/--local-node-no-sudo",
1067
+ default=True,
1068
+ show_default=True,
1069
+ help="Use sudo for systemd installation in node-agent provisioning.",
1070
+ )
1071
+ @click.option(
1072
+ "--lan",
1073
+ is_flag=True,
1074
+ help=(
1075
+ "Listen on every network interface instead of this machine only (backend, gateway, frontend). "
1076
+ "Dev mode signs anyone in as the administrator: use it only on a network you trust."
1077
+ ),
1078
+ )
1079
+ def dev_up(
1080
+ *,
1081
+ backend_only: bool,
1082
+ frontend_only: bool,
1083
+ skip_infra: bool,
1084
+ skip_deps: bool,
1085
+ skip_migrations: bool,
1086
+ headless: bool,
1087
+ modules: str | None,
1088
+ local_node: bool,
1089
+ local_node_host: str,
1090
+ local_node_workdir: str,
1091
+ local_node_branch: str,
1092
+ local_node_backend_url: str,
1093
+ local_node_advertise_host: str,
1094
+ local_node_enrollment_token: str,
1095
+ local_node_sudo: bool,
1096
+ lan: bool = False,
1097
+ ) -> None:
1098
+ """Start backend, worker, and frontend dev servers.
1099
+
1100
+ They listen on this machine only (127.0.0.1): dev mode signs anyone in as
1101
+ the administrator. ``--lan`` opens them to the network for one run.
1102
+
1103
+ Each service launches in its own terminal window, or as a
1104
+ background process with a log file when no GUI is available
1105
+ (headless Linux server) — pass ``--headless`` to force that
1106
+ mode. Mirrors the behaviour of the existing start-dev.ps1 script.
1107
+
1108
+ \b
1109
+ Services started:
1110
+ • Backend → uv run -m faden_backend (http://localhost:8000)
1111
+ • Gateway → uv run -m faden_gateway (http://localhost:8001, /v1 edge)
1112
+ • Worker → uv run taskiq worker … (task processing)
1113
+ • Frontend → npm run dev (http://localhost:5173)
1114
+
1115
+ With --modules (or modules switched on with `faden module enable`):
1116
+ • PII → uv run -m faden_pii (http://127.0.0.1:8003)
1117
+ • MCP → uv run -m faden_mcp (http://127.0.0.1:8007)
1118
+ • Skills → uv run -m faden_skills (http://127.0.0.1:8008)
1119
+ """
1120
+ from faden_cli.core.dev_modules import UnknownModuleError, select_modules
1121
+
1122
+ cfg = load_config()
1123
+ workspace = _find_workspace()
1124
+ # Monorepo-aware: init clones services into <workspace>/fadenstack/…
1125
+ backend_dir = find_service_dir(workspace, "backend")
1126
+ frontend_dir = find_service_dir(workspace, "frontend")
1127
+ api_dir = find_service_dir(workspace, "gateway")
1128
+
1129
+ try:
1130
+ running_modules = [] if frontend_only else select_modules(modules, cfg.profiles)
1131
+ except UnknownModuleError as exc:
1132
+ error(str(exc))
1133
+ sys.exit(2)
1134
+ running_modules = [
1135
+ m for m in running_modules if _module_present(workspace, m)
1136
+ ]
1137
+
1138
+ console.print("[bold magenta]fadenstack Dev Environment[/bold magenta]\n")
1139
+
1140
+ # ── Self-heal the shared (infra) .env ─────────────────────────
1141
+ # `dev down` is a full reset and deletes the generated env files.
1142
+ # Without this, a plain `dev up` afterwards would start infra with
1143
+ # default credentials while the RMQ container (fresh volume) has no
1144
+ # users at all, and backend/gateway would get ACCESS_REFUSED.
1145
+ if not frontend_only:
1146
+ _ensure_shared_env(workspace)
1147
+
1148
+ # ── Ensure backend / gateway .env exist ───────────────────────
1149
+ _ensure_backend_env(backend_dir, workspace)
1150
+ if not frontend_only:
1151
+ _ensure_gateway_env(api_dir, workspace)
1152
+ _ensure_module_envs(workspace, backend_dir, api_dir, running_modules)
1153
+ _apply_bind_hosts(backend_dir, api_dir, lan=lan)
1154
+ if lan:
1155
+ warning(
1156
+ "--lan: the backend, gateway and frontend listen on every interface. Dev mode signs anyone\n"
1157
+ " who reaches them in as the administrator (dev-login, admin/admin)."
1158
+ )
1159
+ elif local_node and local_node_host:
1160
+ warning(
1161
+ f"The backend listens on this machine only; {local_node_host} cannot reach it.\n"
1162
+ " Add --lan to open it to the network."
1163
+ )
1164
+
1165
+ # ── Shared infra ──────────────────────────────────────────────
1166
+ if not skip_infra and not frontend_only:
1167
+ from faden_cli.commands.dev.dev_init import _wait_for_postgres
1168
+ from faden_cli.core.compose import ComposeContext, up as compose_up
1169
+ from faden_cli.core.registry import INFRA_SERVICES
1170
+
1171
+ console.print("[cyan]Checking shared infrastructure…[/cyan]")
1172
+ compose_file = resolve_shared_compose(workspace)
1173
+ if compose_file:
1174
+ env_file = compose_file.parent / ".env"
1175
+ ctx = ComposeContext(
1176
+ compose_files=[str(compose_file)],
1177
+ env_file=str(env_file) if env_file.exists() else None,
1178
+ project_dir=str(compose_file.parent),
1179
+ )
1180
+ rc = compose_up(ctx, services=INFRA_SERVICES, detach=True)
1181
+ if rc != 0:
1182
+ warning("Shared infra compose up reported failures (see output above).")
1183
+ if _wait_for_postgres(timeout=30):
1184
+ success("Shared infrastructure running.")
1185
+ _ensure_databases(backend_dir)
1186
+ else:
1187
+ warning("Postgres did not become ready. Continuing anyway…")
1188
+ else:
1189
+ warning("Shared compose file not found. Skipping infra check.")
1190
+
1191
+ # ── Dependencies ──────────────────────────────────────────────
1192
+ if not skip_deps:
1193
+ if not frontend_only and backend_dir.exists():
1194
+ from faden_cli.commands.dev.dev_init import _install_backend_deps
1195
+ _install_backend_deps(backend_dir)
1196
+ if not backend_only and frontend_dir.exists():
1197
+ from faden_cli.commands.dev.dev_init import _install_frontend_deps
1198
+ _install_frontend_deps(frontend_dir)
1199
+ if not frontend_only and api_dir.exists():
1200
+ from faden_cli.commands.dev.dev_init import _install_backend_deps
1201
+ _install_backend_deps(api_dir)
1202
+ for module in running_modules:
1203
+ _install_module_deps(find_service_dir(workspace, module.dev_dir), module)
1204
+
1205
+ # ── Migrations ────────────────────────────────────────────────
1206
+ if not skip_migrations and not frontend_only and backend_dir.exists():
1207
+ from faden_cli.commands.dev.dev_init import _run_migrations
1208
+ _run_migrations(backend_dir)
1209
+ if not skip_migrations:
1210
+ for module in running_modules:
1211
+ _migrate_module(find_service_dir(workspace, module.dev_dir), module)
1212
+
1213
+ # ── Reclaim the workspace ─────────────────────────────────────
1214
+ # Before anything is launched, not after: a service that is already
1215
+ # running keeps its port, so the one started below exits immediately and
1216
+ # the old one carries on serving. Nothing about that looks like a
1217
+ # failure -- ``dev up`` reports success, the logs show a clean start, and
1218
+ # the running system quietly ignores every change made since.
1219
+ console.print("\n[cyan]Stopping anything already running here…[/cyan]")
1220
+ # The ports of the modules about to start too -- and only theirs: a port
1221
+ # of a module not started here may be someone else's.
1222
+ _DEV_PORTS.update({m.port: _module_label(m) for m in running_modules})
1223
+ _reclaim_workspace(workspace)
1224
+
1225
+ started: list[str] = []
1226
+
1227
+ # ── Launch modules ────────────────────────────────────────────
1228
+ # First, so they are loading (PII reads a 560 MB model) while the rest
1229
+ # start. Nothing waits for them: the gateway calls them per request.
1230
+ for module in running_modules:
1231
+ label = _module_label(module)
1232
+ module_dir = find_service_dir(workspace, module.dev_dir)
1233
+ console.print(f"\n[cyan]Launching {label}…[/cyan]")
1234
+ if _launch_terminal(label, module_dir, f"uv run -m {module.dev_dir}", headless=headless):
1235
+ success(f"{label} → http://127.0.0.1:{module.port}")
1236
+ started.append(label)
1237
+ else:
1238
+ error(f"{label} failed to start.")
1239
+
1240
+ # ── Launch backend ────────────────────────────────────────────
1241
+ if not frontend_only:
1242
+ if not backend_dir.exists():
1243
+ error(f"Backend directory not found: {backend_dir}")
1244
+ else:
1245
+ console.print("\n[cyan]Launching backend…[/cyan]")
1246
+ if _launch_terminal("Backend", backend_dir, "uv run -m faden_backend", headless=headless):
1247
+ success("Backend server → http://localhost:8000")
1248
+ started.append("Backend")
1249
+ else:
1250
+ error("Backend failed to start.")
1251
+
1252
+ # Taskiq worker
1253
+ _stop_old_workers()
1254
+ console.print("[cyan]Launching taskiq worker…[/cyan]")
1255
+ if _launch_terminal(
1256
+ "Worker", backend_dir,
1257
+ "uv run taskiq worker faden_backend.tkq:broker faden_backend.services.llm.tasks faden_backend.services.rag_lite.tasks",
1258
+ headless=headless,
1259
+ ):
1260
+ success("Taskiq worker started.")
1261
+ started.append("Worker")
1262
+ else:
1263
+ error("Taskiq worker failed to start.")
1264
+
1265
+ # API gateway (OpenAI-compatible /v1 edge)
1266
+ if not api_dir.exists():
1267
+ warning(f"API gateway directory not found: {api_dir} — skipping.")
1268
+ else:
1269
+ console.print("[cyan]Launching API gateway…[/cyan]")
1270
+ if _launch_terminal("Gateway", api_dir, "uv run -m faden_gateway", headless=headless):
1271
+ success("API gateway → http://localhost:8001")
1272
+ started.append("API gateway")
1273
+ else:
1274
+ error("API gateway failed to start.")
1275
+
1276
+ # ── Launch frontend ───────────────────────────────────────────
1277
+ if not backend_only:
1278
+ if not frontend_dir.exists():
1279
+ error(f"Frontend directory not found: {frontend_dir}")
1280
+ else:
1281
+ console.print("\n[cyan]Launching frontend…[/cyan]")
1282
+ if _launch_terminal("Frontend", frontend_dir, _frontend_command(lan=lan), headless=headless):
1283
+ success("Frontend server → http://localhost:5173")
1284
+ started.append("Frontend")
1285
+ else:
1286
+ error("Frontend failed to start.")
1287
+
1288
+ # ── Optional local-node provisioning (after backend is up) ───
1289
+ if local_node:
1290
+ from faden_cli.core.local_node import ( # noqa: PLC0415
1291
+ create_enrollment_token,
1292
+ provision_local_node_agent,
1293
+ )
1294
+
1295
+ enrollment_token = local_node_enrollment_token
1296
+
1297
+ # Auto-create token if none provided: wait for backend,
1298
+ # then bootstrap (idempotent — skips if already done) to
1299
+ # obtain an API token and create an enrollment token.
1300
+ if not enrollment_token.strip():
1301
+ from faden_cli.core.bootstrap import ( # noqa: PLC0415
1302
+ bootstrap_interactive,
1303
+ sign_in_with_saved_credentials,
1304
+ wait_for_backend,
1305
+ )
1306
+
1307
+ dev_backend_url = local_node_backend_url.strip() or "http://localhost:8000"
1308
+ console.print(" [dim]Waiting for backend to become healthy…[/dim]")
1309
+ if wait_for_backend(dev_backend_url, timeout=60):
1310
+ shared_dir = workspace / "shared"
1311
+ if not shared_dir.is_dir():
1312
+ shared_dir = workspace / "fadenstack" / "shared"
1313
+ creds = bootstrap_interactive(
1314
+ dev_backend_url,
1315
+ shared_dir,
1316
+ auto_confirm=True,
1317
+ )
1318
+ if creds and creds.get("api_token"):
1319
+ info("No enrollment token provided — creating one automatically…")
1320
+ enrollment_token = create_enrollment_token(dev_backend_url, creds["api_token"]) or ""
1321
+ elif not creds:
1322
+ # Already bootstrapped: sign in as the administrator saved then (the setup token
1323
+ # it saved too lasted an hour).
1324
+ api_token = sign_in_with_saved_credentials(dev_backend_url, shared_dir)
1325
+ if api_token:
1326
+ info("Signed in with the saved credentials — creating an enrollment token…")
1327
+ enrollment_token = create_enrollment_token(dev_backend_url, api_token) or ""
1328
+ else:
1329
+ warning("Backend not healthy — cannot auto-create enrollment token.")
1330
+
1331
+ if not enrollment_token.strip():
1332
+ warning(
1333
+ "No enrollment token available. Provide one with"
1334
+ " --local-node-enrollment-token."
1335
+ )
1336
+
1337
+ branch = local_node_branch.strip() or (cfg.dev.branch if cfg.dev and cfg.dev.branch else "main")
1338
+ remote_host = local_node_host.strip() or None
1339
+ method = cfg.dev.clone_method if cfg.dev and cfg.dev.clone_method else "https"
1340
+ github_token = cfg.dev.github_token if cfg.dev else ""
1341
+
1342
+ ok = provision_local_node_agent(
1343
+ workspace=workspace,
1344
+ branch=branch,
1345
+ backend_url=local_node_backend_url,
1346
+ advertise_host=local_node_advertise_host,
1347
+ enrollment_token=enrollment_token,
1348
+ remote_host=remote_host,
1349
+ use_sudo=local_node_sudo,
1350
+ method=method,
1351
+ github_token=github_token,
1352
+ workdir_override=local_node_workdir.strip() or None,
1353
+ )
1354
+ if not ok:
1355
+ error("Local-node provisioning failed.")
1356
+ sys.exit(1)
1357
+
1358
+ # ── Summary ───────────────────────────────────────────────────
1359
+ if started:
1360
+ console.print("\n[bold green]Dev environment started![/bold green]")
1361
+ from faden_cli.core.workspace import dev_logs_dir
1362
+
1363
+ if headless or platform.system() == "Linux":
1364
+ log_dir = dev_logs_dir()
1365
+ console.print(f"[dim]Background logs: {log_dir}/ (pids in log filenames)\n[/dim]")
1366
+ console.print("[dim]Stop services: faden dev down (--keep-infra to keep containers; --volumes to also wipe dev data)[/dim]")
1367
+ else:
1368
+ console.print("[dim]Each service runs in its own terminal window.[/dim]")
1369
+ console.print("[dim]Close windows or Ctrl+C to stop.[/dim]")
1370
+ else:
1371
+ console.print("\n[bold red]No dev services started.[/bold red]")
1372
+ if headless:
1373
+ console.print("[dim]Check the errors above (e.g. missing deps) and re-run.[/dim]")
1374
+ console.print("[dim]You can start individual services manually from the workspace[/dim]")
1375
+ sys.exit(1)
1376
+
1377
+ endpoints = []
1378
+ module_names = {m.name for m in running_modules}
1379
+ for name, url in DEV_ENDPOINTS:
1380
+ if frontend_only and name in ("Backend", "API Docs", "Worker", "LLM API"):
1381
+ continue
1382
+ if backend_only and name == "Frontend":
1383
+ continue
1384
+ module_name = DEV_MODULE_ENDPOINTS.get(name)
1385
+ if module_name is not None and module_name not in module_names:
1386
+ continue # a module that is not running
1387
+ endpoints.append((name, url))
1388
+
1389
+ console.print()
1390
+ for name, url in endpoints:
1391
+ console.print(f" [bold]{name:12s}[/bold] {url}")
1392
+ console.print()