Files
call-center/scripts/local_stack.py
T

591 lines
24 KiB
Python

from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
from typing import Any
from urllib.error import URLError
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_RUNTIME_DIR = ROOT / ".local_stack"
DEFAULT_DATA_DIR = ROOT / ".data_local"
DEFAULT_ENV_FILES = [
ROOT / ".env.production",
ROOT / ".env.local",
ROOT / ".env",
]
def _env_int(name: str, default: int, env: dict[str, str] | None = None) -> int:
source = env or os.environ
raw = str(source.get(name, "")).strip()
if not raw:
return default
try:
value = int(raw)
except ValueError as exc:
raise RuntimeError(f"{name} must be an integer") from exc
if value <= 0:
raise RuntimeError(f"{name} must be positive")
return value
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def load_env_file(path: Path) -> dict[str, str]:
if not path.exists():
return {}
result: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if not key:
continue
if value and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
result[key] = value
return result
def load_stack_env(extra_env_files: list[Path] | None = None) -> dict[str, str]:
env = os.environ.copy()
for path in DEFAULT_ENV_FILES:
for key, value in load_env_file(path).items():
env.setdefault(key, value)
for path in extra_env_files or []:
for key, value in load_env_file(path).items():
env[key] = value
return env
def build_service_specs(env: dict[str, str] | None = None) -> list[dict[str, Any]]:
gateway_port = _env_int("LOCAL_STACK_GATEWAY_PORT", 8080, env)
return [
{"name": "auth", "port": 8001, "module": "services.auth_service.app:app"},
{"name": "audit", "port": 8002, "module": "services.audit_service.app:app"},
{"name": "customer", "port": 8003, "module": "services.customer_service.app:app"},
{"name": "interaction", "port": 8004, "module": "services.interaction_service.app:app"},
{"name": "routing", "port": 8005, "module": "services.routing_service.app:app"},
{"name": "voice", "port": 8006, "module": "services.voice_adapter_service.app:app"},
{"name": "recording", "port": 8013, "module": "services.recording_service.app:app"},
{"name": "ivr", "port": 8014, "module": "services.ivr_service.app:app"},
{"name": "event-bus", "port": 8015, "module": "services.event_bus_service.app:app"},
{"name": "asterisk-bridge", "port": 8016, "module": "services.asterisk_bridge_service.app:app"},
{"name": "telegram", "port": 8007, "module": "services.telegram_adapter_service.app:app"},
{"name": "whatsapp", "port": 8019, "module": "services.whatsapp_adapter_service.app:app"},
{"name": "ai", "port": 8017, "module": "services.ai_orchestrator_service.app:app"},
{"name": "streaming-asr-sidecar", "port": 8021, "module": "services.streaming_asr_sidecar_service.app:app"},
{"name": "ai-voice-runtime", "port": 8018, "module": "services.ai_voice_runtime_service.app:app"},
{"name": "webchat", "port": 8011, "module": "services.webchat_adapter_service.app:app"},
{"name": "email", "port": 8012, "module": "services.email_adapter_service.app:app"},
{"name": "kb", "port": 8008, "module": "services.kb_service.app:app"},
{"name": "reporting", "port": 8009, "module": "services.reporting_service.app:app"},
{"name": "supervisor", "port": 8010, "module": "services.supervisor_service.app:app"},
{"name": "gateway", "port": gateway_port, "module": "gateway.app:app"},
]
def build_gateway_env(env: dict[str, str] | None = None) -> dict[str, str]:
ports = {spec["name"]: int(spec["port"]) for spec in build_service_specs(env)}
base = "http://127.0.0.1"
return {
"AUTH_SERVICE_URL": f"{base}:{ports['auth']}",
"AUDIT_SERVICE_URL": f"{base}:{ports['audit']}",
"CUSTOMER_SERVICE_URL": f"{base}:{ports['customer']}",
"INTERACTION_SERVICE_URL": f"{base}:{ports['interaction']}",
"ROUTING_SERVICE_URL": f"{base}:{ports['routing']}",
"VOICE_ADAPTER_SERVICE_URL": f"{base}:{ports['voice']}",
"RECORDING_SERVICE_URL": f"{base}:{ports['recording']}",
"IVR_SERVICE_URL": f"{base}:{ports['ivr']}",
"EVENT_BUS_SERVICE_URL": f"{base}:{ports['event-bus']}",
"ASTERISK_BRIDGE_SERVICE_URL": f"{base}:{ports['asterisk-bridge']}",
"TELEGRAM_ADAPTER_SERVICE_URL": f"{base}:{ports['telegram']}",
"WHATSAPP_ADAPTER_SERVICE_URL": f"{base}:{ports['whatsapp']}",
"AI_ORCHESTRATOR_SERVICE_URL": f"{base}:{ports['ai']}",
"AI_VOICE_RUNTIME_SERVICE_URL": f"{base}:{ports['ai-voice-runtime']}",
"WEBCHAT_ADAPTER_SERVICE_URL": f"{base}:{ports['webchat']}",
"EMAIL_ADAPTER_SERVICE_URL": f"{base}:{ports['email']}",
"KB_SERVICE_URL": f"{base}:{ports['kb']}",
"REPORTING_SERVICE_URL": f"{base}:{ports['reporting']}",
"SUPERVISOR_SERVICE_URL": f"{base}:{ports['supervisor']}",
}
def service_bind_host(spec_name: str, env: dict[str, str]) -> str:
if spec_name == "gateway":
return env.get("LOCAL_STACK_GATEWAY_HOST", "127.0.0.1").strip() or "127.0.0.1"
return env.get("LOCAL_STACK_SERVICE_HOST", "127.0.0.1").strip() or "127.0.0.1"
def service_health_host(spec_name: str, env: dict[str, str]) -> str:
if spec_name == "gateway":
configured = env.get("LOCAL_STACK_GATEWAY_HEALTH_HOST", "").strip()
if configured:
return configured
bind_host = service_bind_host(spec_name, env)
if bind_host in {"0.0.0.0", "::"}:
return "127.0.0.1"
return bind_host
def public_base_url(env: dict[str, str] | None = None) -> str:
current_env = env or {}
default_url = "http://127.0.0.1:8080"
return current_env.get("LOCAL_STACK_PUBLIC_BASE_URL", default_url).strip() or default_url
def build_manifest_payload(
runtime_dir: Path,
data_dir: Path,
services: list[dict[str, Any]],
env: dict[str, str] | None = None,
) -> dict[str, Any]:
return {
"generated_at": utc_now_iso(),
"root": str(ROOT),
"runtime_dir": str(runtime_dir),
"data_dir": str(data_dir),
"base_url": public_base_url(env),
"services": services,
}
def manifest_path(runtime_dir: Path) -> Path:
return runtime_dir / "manifest.json"
def load_manifest(runtime_dir: Path) -> dict[str, Any] | None:
path = manifest_path(runtime_dir)
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def save_manifest(runtime_dir: Path, payload: dict[str, Any]) -> None:
runtime_dir.mkdir(parents=True, exist_ok=True)
manifest_path(runtime_dir).write_text(json.dumps(payload, indent=2), encoding="utf-8")
def remove_manifest(runtime_dir: Path) -> None:
path = manifest_path(runtime_dir)
if path.exists():
path.unlink()
def is_pid_running(pid: int) -> bool:
if pid <= 0:
return False
if os.name == "nt":
result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
check=False,
)
if result.returncode != 0:
return False
return str(pid) in (result.stdout or "")
try:
os.kill(pid, 0)
except (OSError, SystemError):
return False
return True
def resolve_listener_pid(port: int) -> int | None:
if os.name == "nt":
result = subprocess.run(
["netstat", "-ano", "-p", "tcp"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
check=False,
)
if result.returncode != 0:
return None
markers = {f"127.0.0.1:{port}", f"0.0.0.0:{port}", f"[::]:{port}"}
for raw_line in (result.stdout or "").splitlines():
line = raw_line.strip()
if "LISTENING" not in line:
continue
parts = line.split()
if len(parts) < 5:
continue
local_address = parts[1]
state = parts[3]
pid_raw = parts[4]
if local_address not in markers or state != "LISTENING":
continue
try:
pid = int(pid_raw)
except ValueError:
continue
if pid > 0:
return pid
return None
return None
def wait_for_health(host: str, port: int, retries: int = 80, delay: float = 0.25) -> None:
url = f"http://{host}:{port}/health"
for _ in range(retries):
try:
with urlopen(url, timeout=1.5) as response: # noqa: S310 - local health probe
if response.status == 200:
return
except (OSError, URLError):
pass
time.sleep(delay)
raise RuntimeError(f"Service did not become healthy: {url}")
def create_log_paths(runtime_dir: Path, name: str) -> tuple[Path, Path]:
log_dir = runtime_dir / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
return log_dir / f"{name}.stdout.log", log_dir / f"{name}.stderr.log"
def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_env: dict[str, str]) -> dict[str, Any]:
env = base_env.copy()
env["CC_DATA_DIR"] = str(data_dir)
env["EVENT_BUS_ENABLED"] = env.get("EVENT_BUS_ENABLED", "0")
env["EVENT_BUS_URL"] = env.get("EVENT_BUS_URL", "amqp://guest:guest@127.0.0.1:5672/")
env["EVENT_BUS_EXCHANGE"] = env.get("EVENT_BUS_EXCHANGE", "mvpcc.domain.events")
env["EVENT_BUS_DISPATCH_BATCH_SIZE"] = env.get("EVENT_BUS_DISPATCH_BATCH_SIZE", "50")
env["EVENT_BUS_MAX_ATTEMPTS"] = env.get("EVENT_BUS_MAX_ATTEMPTS", "5")
env["EVENT_BUS_POLL_INTERVAL_SECONDS"] = env.get("EVENT_BUS_POLL_INTERVAL_SECONDS", "2")
env["EVENT_BUS_CONSUMER_ENABLED"] = env.get("EVENT_BUS_CONSUMER_ENABLED", "1")
env["EVENT_BUS_AUDIT_QUEUE"] = env.get("EVENT_BUS_AUDIT_QUEUE", "mvpcc.audit.events")
env["EVENT_BUS_REPORTING_QUEUE"] = env.get("EVENT_BUS_REPORTING_QUEUE", "mvpcc.reporting.events")
env["ASTERISK_BRIDGE_ENABLED"] = env.get("ASTERISK_BRIDGE_ENABLED", "0")
env["ASTERISK_AMI_EVENT_PREFIX"] = env.get("ASTERISK_AMI_EVENT_PREFIX", "MVPCC")
env["ASTERISK_QUEUE_MAP_JSON"] = env.get("ASTERISK_QUEUE_MAP_JSON", "{}")
env["ASTERISK_CALLCONTROL_ENABLED"] = env.get("ASTERISK_CALLCONTROL_ENABLED", "0")
env["ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS"] = env.get(
"ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS",
"10",
)
env["ASTERISK_WEBRTC_ENABLED"] = env.get("ASTERISK_WEBRTC_ENABLED", "0")
env["ASTERISK_WEBRTC_WS_URL"] = env.get("ASTERISK_WEBRTC_WS_URL", "")
env["ASTERISK_WEBRTC_ICE_SERVERS_JSON"] = env.get("ASTERISK_WEBRTC_ICE_SERVERS_JSON", "[]")
env["ASTERISK_OPERATOR_EXTENSION_MAP_JSON"] = env.get("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", "{}")
env["ASTERISK_BROWSER_SIP_MAP_JSON"] = env.get("ASTERISK_BROWSER_SIP_MAP_JSON", "{}")
env["ASTERISK_TRANSFER_TARGET_MAP_JSON"] = env.get("ASTERISK_TRANSFER_TARGET_MAP_JSON", "{}")
env["AI_PROVIDER"] = env.get("AI_PROVIDER", "stub")
env["AI_API_BASE"] = env.get("AI_API_BASE", "")
env["AI_API_KEY"] = env.get("AI_API_KEY", "")
env["AI_MODEL"] = env.get("AI_MODEL", "stub-telegram-assistant")
env["AI_TIMEOUT_SECONDS"] = env.get("AI_TIMEOUT_SECONDS", "20")
env["AI_TELEGRAM_ENABLED"] = env.get("AI_TELEGRAM_ENABLED", "0")
env["AI_TELEGRAM_ALWAYS_REPLY"] = env.get("AI_TELEGRAM_ALWAYS_REPLY", "0")
env["AI_TELEGRAM_MAX_CONTEXT_MESSAGES"] = env.get("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", "12")
env["AI_TELEGRAM_MAX_KB_RESULTS"] = env.get("AI_TELEGRAM_MAX_KB_RESULTS", "3")
env["AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get(
"AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD",
"0.65",
)
env["AI_WHATSAPP_ENABLED"] = env.get("AI_WHATSAPP_ENABLED", "0")
env["AI_WHATSAPP_ALWAYS_REPLY"] = env.get("AI_WHATSAPP_ALWAYS_REPLY", "0")
env["AI_WHATSAPP_MAX_CONTEXT_MESSAGES"] = env.get("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", "12")
env["AI_WHATSAPP_MAX_KB_RESULTS"] = env.get("AI_WHATSAPP_MAX_KB_RESULTS", "3")
env["AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get(
"AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD",
"0.65",
)
env["AI_VOICE_RUNTIME_SERVICE_URL"] = env.get("AI_VOICE_RUNTIME_SERVICE_URL", "http://127.0.0.1:8018")
env["AI_VOICE_ENABLED"] = env.get("AI_VOICE_ENABLED", "0")
env["AI_VOICE_QUEUE_CONFIG_JSON"] = env.get("AI_VOICE_QUEUE_CONFIG_JSON", "{}")
env["AI_VOICE_ASR_PROVIDER"] = env.get("AI_VOICE_ASR_PROVIDER", "openai")
env["AI_VOICE_TTS_PROVIDER"] = env.get("AI_VOICE_TTS_PROVIDER", "yandex")
env["AI_VOICE_POLICY_MODE"] = env.get("AI_VOICE_POLICY_MODE", "v2_fast_conversational")
env["AI_VOICE_V2_ENABLED"] = env.get("AI_VOICE_V2_ENABLED", "1")
env["AI_VOICE_V2_QUEUE_CODES"] = env.get("AI_VOICE_V2_QUEUE_CODES", "voice_lab_ai")
env["AI_VOICE_V2_ACK_MODE"] = env.get("AI_VOICE_V2_ACK_MODE", "immediate_short")
env["AI_VOICE_V2_DUPLEX_ENABLED"] = env.get("AI_VOICE_V2_DUPLEX_ENABLED", "1")
env["AI_VOICE_V2_STREAMING_ASR_BACKEND"] = env.get("AI_VOICE_V2_STREAMING_ASR_BACKEND", "local_sidecar")
env["AI_VOICE_V2_STREAMING_ASR_BASE_URL"] = env.get("AI_VOICE_V2_STREAMING_ASR_BASE_URL", "http://127.0.0.1:8021")
env["AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS"] = env.get("AI_VOICE_V2_STREAMING_ASR_TIMEOUT_SECONDS", "4")
env["AI_VOICE_V2_STREAMING_ASR_MODEL"] = env.get("AI_VOICE_V2_STREAMING_ASR_MODEL", "base")
env["AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE"] = env.get("AI_VOICE_V2_STREAMING_ASR_COMPUTE_TYPE", "int8")
env["AI_VOICE_V2_STREAMING_ASR_DEVICE"] = env.get("AI_VOICE_V2_STREAMING_ASR_DEVICE", "cpu")
env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"] = env.get("AI_VOICE_V2_STREAMING_ASR_CACHE_DIR", str(ROOT / ".models" / "faster_whisper"))
env["AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES"] = env.get("AI_VOICE_V2_STREAMING_ASR_SUPPORTED_LANGUAGES", "ru")
env["AI_VOICE_V2_PREBAKED_ACK_ENABLED"] = env.get("AI_VOICE_V2_PREBAKED_ACK_ENABLED", "1")
env["AI_VOICE_V2_PREBAKED_ACK_DIR"] = env.get("AI_VOICE_V2_PREBAKED_ACK_DIR", str(DATA_DIR / "voice_v2_ack_bank"))
env["AI_VOICE_V2_STREAMING_TTS"] = env.get("AI_VOICE_V2_STREAMING_TTS", "1")
env["AI_VOICE_V2_PARTIAL_ASR"] = env.get("AI_VOICE_V2_PARTIAL_ASR", "1")
env["AI_VOICE_V2_EMOTIVE_ACK_ENABLED"] = env.get("AI_VOICE_V2_EMOTIVE_ACK_ENABLED", "1")
env["AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY"] = env.get("AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY", "1")
env["AI_VOICE_MAX_CONTEXT_SEGMENTS"] = env.get("AI_VOICE_MAX_CONTEXT_SEGMENTS", "12")
env["AI_VOICE_HANDOFF_TIMEOUT_SECONDS"] = env.get("AI_VOICE_HANDOFF_TIMEOUT_SECONDS", "8")
env["AI_VOICE_VAD_TRAILING_SILENCE_MS"] = env.get("AI_VOICE_VAD_TRAILING_SILENCE_MS", "400")
env["AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS"] = env.get(
"AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS",
"svc:ai-voice-runtime",
)
env["ASTERISK_CALLCONTROL_CLAIM_CONTEXT"] = env.get("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim")
env["ASTERISK_CALLCONTROL_TRANSFER_CONTEXT"] = env.get(
"ASTERISK_CALLCONTROL_TRANSFER_CONTEXT",
"mvpcc-transfer",
)
if spec["name"] == "recording":
env["CC_RECORDINGS_DIR"] = str((data_dir / "recordings").resolve())
env["RECORDING_MAX_BYTES"] = env.get("RECORDING_MAX_BYTES", "26214400")
if spec["name"] == "streaming-asr-sidecar":
Path(env["AI_VOICE_V2_STREAMING_ASR_CACHE_DIR"]).mkdir(parents=True, exist_ok=True)
if spec["name"] == "gateway":
env.update(build_gateway_env(env))
stdout_path, stderr_path = create_log_paths(runtime_dir, str(spec["name"]))
creationflags = 0
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
cmd = [
sys.executable,
"-m",
"uvicorn",
str(spec["module"]),
"--host",
service_bind_host(str(spec["name"]), env),
"--port",
str(spec["port"]),
]
with stdout_path.open("ab") as stdout_file, stderr_path.open("ab") as stderr_file:
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdin=subprocess.DEVNULL,
stdout=stdout_file,
stderr=stderr_file,
creationflags=creationflags,
)
time.sleep(0.15)
if proc.poll() is not None:
raise RuntimeError(f"{spec['name']} failed to start; check {stderr_path}")
return {
"name": spec["name"],
"module": spec["module"],
"port": spec["port"],
"pid": proc.pid,
"stdout_log": str(stdout_path),
"stderr_log": str(stderr_path),
}
def terminate_pid(pid: int) -> None:
if not is_pid_running(pid):
return
try:
os.kill(pid, signal.SIGTERM)
except OSError:
return
for _ in range(20):
if not is_pid_running(pid):
return
time.sleep(0.2)
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
def running_services(manifest: dict[str, Any] | None) -> list[dict[str, Any]]:
if not manifest or not isinstance(manifest, dict):
return []
active: list[dict[str, Any]] = []
for svc in manifest.get("services", []):
if not isinstance(svc, dict):
continue
try:
pid = int(svc.get("pid", 0))
except Exception: # noqa: BLE001
continue
if is_pid_running(pid):
active.append(svc)
return active
def start_stack(runtime_dir: Path, data_dir: Path, force_restart: bool, env: dict[str, str]) -> int:
service_specs = build_service_specs(env)
current = load_manifest(runtime_dir)
active = running_services(current)
if active and not force_restart:
print("Local stack is already running.")
print(f"Base URL: {current.get('base_url', public_base_url(env))}")
print(f"Logs: {runtime_dir / 'logs'}")
print("Use `python scripts\\local_stack.py stop` first or rerun with --force-restart.")
return 0
if active and force_restart:
stop_stack(runtime_dir)
if current and not active:
remove_manifest(runtime_dir)
runtime_dir.mkdir(parents=True, exist_ok=True)
data_dir.mkdir(parents=True, exist_ok=True)
launched: list[dict[str, Any]] = []
try:
for spec in service_specs:
meta = spawn_service(spec, runtime_dir, data_dir, env)
wait_for_health(service_health_host(str(spec["name"]), env), int(meta["port"]))
if not is_pid_running(int(meta["pid"])):
listener_pid = resolve_listener_pid(int(meta["port"]))
if listener_pid and is_pid_running(listener_pid):
meta["pid"] = listener_pid
else:
raise RuntimeError(f"{meta['name']} exited before becoming ready; check {meta['stderr_log']}")
launched.append(meta)
print(f"[ok] {meta['name']} on {meta['port']} (pid {meta['pid']})")
except Exception as exc:
print(f"[fail] {exc}")
print(f"Check logs: {runtime_dir / 'logs'}")
print("If ports are already busy, stop old local processes and rerun.")
for meta in reversed(launched):
terminate_pid(int(meta["pid"]))
return 1
save_manifest(runtime_dir, build_manifest_payload(runtime_dir, data_dir, launched, env))
print("")
print("Local stack is ready.")
print(f"UI: {public_base_url(env)}/")
print(f"Logs: {runtime_dir / 'logs'}")
print("Stop: python scripts\\local_stack.py stop")
return 0
def stop_stack(runtime_dir: Path) -> int:
current = load_manifest(runtime_dir)
if not current:
print("Local stack is not running (no manifest found).")
return 0
services = current.get("services", []) if isinstance(current, dict) else []
for meta in reversed(services):
if not isinstance(meta, dict):
continue
name = str(meta.get("name", "service"))
try:
pid = int(meta.get("pid", 0))
except Exception: # noqa: BLE001
print(f"[skip] {name} (pid invalid)")
continue
if is_pid_running(pid):
terminate_pid(pid)
print(f"[stopped] {name} (pid {pid})")
else:
print(f"[skip] {name} (pid {pid}) already stopped")
remove_manifest(runtime_dir)
print("Local stack is stopped.")
return 0
def status_stack(runtime_dir: Path, env: dict[str, str]) -> int:
current = load_manifest(runtime_dir)
if not current:
print("Local stack is not running.")
return 0
print(f"Base URL: {current.get('base_url', public_base_url(env))}")
print(f"Data dir: {current.get('data_dir', str(DEFAULT_DATA_DIR))}")
print(f"Logs: {runtime_dir / 'logs'}")
print("")
services = current.get("services", []) if isinstance(current, dict) else []
for meta in services:
if not isinstance(meta, dict):
continue
try:
pid = int(meta.get("pid", 0))
except Exception: # noqa: BLE001
pid = 0
label = "running" if is_pid_running(pid) else "stopped"
print(f"- {meta.get('name')}: {label} on {meta.get('port')} (pid {pid})")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Manage the local MVP stack without opening extra consoles")
parser.add_argument("command", choices=["start", "stop", "status"], help="Action to perform")
parser.add_argument(
"--runtime-dir",
default=str(DEFAULT_RUNTIME_DIR),
help="Directory for manifest and logs",
)
parser.add_argument(
"--data-dir",
default=None,
help="Directory for local runtime data and recordings",
)
parser.add_argument(
"--env-file",
action="append",
default=[],
help="Additional env file to apply after the default stack env files",
)
parser.add_argument(
"--force-restart",
action="store_true",
help="Stop the existing managed stack before starting a new one",
)
args = parser.parse_args()
extra_env_files: list[Path] = []
for raw_path in args.env_file:
path = Path(raw_path)
if not path.is_absolute():
path = (ROOT / path).resolve()
extra_env_files.append(path)
env = load_stack_env(extra_env_files)
runtime_dir = Path(args.runtime_dir)
if not runtime_dir.is_absolute():
runtime_dir = (ROOT / runtime_dir).resolve()
raw_data_dir = args.data_dir or env.get("CC_DATA_DIR") or str(DEFAULT_DATA_DIR)
data_dir = Path(raw_data_dir)
if not data_dir.is_absolute():
data_dir = (ROOT / data_dir).resolve()
if args.command == "start":
return start_stack(runtime_dir, data_dir, args.force_restart, env)
if args.command == "stop":
return stop_stack(runtime_dir)
return status_stack(runtime_dir, env)
if __name__ == "__main__":
raise SystemExit(main())