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.
- faden_cli/__init__.py +16 -0
- faden_cli/__main__.py +9 -0
- faden_cli/bundle/shared/.gitignore +25 -0
- faden_cli/bundle/shared/LICENSE +187 -0
- faden_cli/bundle/shared/NOTICE +15 -0
- faden_cli/bundle/shared/OFFICIAL.md +9 -0
- faden_cli/bundle/shared/README.md +16 -0
- faden_cli/bundle/shared/agent-binaries/.gitignore +5 -0
- faden_cli/bundle/shared/alloy/config.alloy +40 -0
- faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +26 -0
- faden_cli/bundle/shared/docker-compose.gpu.yaml +34 -0
- faden_cli/bundle/shared/docker-compose.yaml +998 -0
- faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +2099 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +10 -0
- faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +211 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +9 -0
- faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +15 -0
- faden_cli/bundle/shared/initdb/01-init.sql +32 -0
- faden_cli/bundle/shared/loki/local-config.yaml +32 -0
- faden_cli/bundle/shared/nginx/locations.conf +245 -0
- faden_cli/bundle/shared/nginx/maintenance.html +239 -0
- faden_cli/bundle/shared/nginx/nginx-tls.conf +79 -0
- faden_cli/bundle/shared/nginx/nginx.conf +44 -0
- faden_cli/bundle/shared/nginx/rate-limits.conf +42 -0
- faden_cli/bundle/shared/nginx/security-headers.conf +22 -0
- faden_cli/bundle/shared/nginx/select-config.sh +37 -0
- faden_cli/bundle/shared/prometheus/prometheus.yml +40 -0
- faden_cli/bundle/shared/rabbitmq/definitions.json.example +37 -0
- faden_cli/bundle/shared/tls/README.md +30 -0
- faden_cli/cli.py +158 -0
- faden_cli/commands/__init__.py +1 -0
- faden_cli/commands/admin.py +161 -0
- faden_cli/commands/backup.py +159 -0
- faden_cli/commands/config.py +146 -0
- faden_cli/commands/deploy.py +949 -0
- faden_cli/commands/dev/__init__.py +1 -0
- faden_cli/commands/dev/dev_doctor.py +46 -0
- faden_cli/commands/dev/dev_down.py +241 -0
- faden_cli/commands/dev/dev_group.py +24 -0
- faden_cli/commands/dev/dev_init.py +710 -0
- faden_cli/commands/dev/dev_pull.py +297 -0
- faden_cli/commands/dev/dev_status.py +151 -0
- faden_cli/commands/dev/dev_up.py +1392 -0
- faden_cli/commands/doctor.py +225 -0
- faden_cli/commands/down.py +75 -0
- faden_cli/commands/logs_cmd.py +41 -0
- faden_cli/commands/maintenance.py +74 -0
- faden_cli/commands/module.py +113 -0
- faden_cli/commands/observe.py +95 -0
- faden_cli/commands/ports.py +91 -0
- faden_cli/commands/rebuild.py +158 -0
- faden_cli/commands/restore.py +139 -0
- faden_cli/commands/runtime_images.py +174 -0
- faden_cli/commands/status.py +71 -0
- faden_cli/commands/tls.py +722 -0
- faden_cli/commands/tune.py +37 -0
- faden_cli/commands/up.py +64 -0
- faden_cli/commands/upgrade.py +470 -0
- faden_cli/commands/version.py +78 -0
- faden_cli/core/__init__.py +1 -0
- faden_cli/core/api_client.py +77 -0
- faden_cli/core/backup.py +717 -0
- faden_cli/core/bootstrap.py +291 -0
- faden_cli/core/bundle.py +110 -0
- faden_cli/core/clickhouse.py +61 -0
- faden_cli/core/compose.py +484 -0
- faden_cli/core/console.py +106 -0
- faden_cli/core/detect.py +580 -0
- faden_cli/core/dev_modules.py +149 -0
- faden_cli/core/docker_env.py +147 -0
- faden_cli/core/edge.py +94 -0
- faden_cli/core/env_gen.py +598 -0
- faden_cli/core/git.py +219 -0
- faden_cli/core/install.py +288 -0
- faden_cli/core/installation.py +186 -0
- faden_cli/core/local_node.py +719 -0
- faden_cli/core/maintenance.py +82 -0
- faden_cli/core/private_files.py +133 -0
- faden_cli/core/registry.py +574 -0
- faden_cli/core/release_feed.py +342 -0
- faden_cli/core/rmq.py +135 -0
- faden_cli/core/runtime_images.py +309 -0
- faden_cli/core/schedule.py +278 -0
- faden_cli/core/settings.py +159 -0
- faden_cli/core/sysinfo.py +303 -0
- faden_cli/core/tls.py +790 -0
- faden_cli/core/tune.py +154 -0
- faden_cli/core/update_check.py +254 -0
- faden_cli/core/workspace.py +155 -0
- faden_cli/templates/env.j2 +33 -0
- faden_cli/tui/__init__.py +1 -0
- faden_cli/tui/widgets/__init__.py +1 -0
- faden_cli/tui/wizard/__init__.py +1 -0
- fadenstack-0.4.2.dist-info/METADATA +304 -0
- fadenstack-0.4.2.dist-info/RECORD +99 -0
- fadenstack-0.4.2.dist-info/WHEEL +4 -0
- fadenstack-0.4.2.dist-info/entry_points.txt +3 -0
- fadenstack-0.4.2.dist-info/licenses/LICENSE +187 -0
- 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()
|