release: bump version to 0.53.0

This commit is contained in:
rayd1o
2026-05-13 08:05:43 +08:00
parent b87cb310fd
commit d9efd98d26
56 changed files with 2318 additions and 243 deletions

440
planet.sh
View File

@@ -1,6 +1,7 @@
#!/usr/bin/env zsh
set -e
set -o pipefail
SCRIPT_PATH="${(%):-%N}"
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
@@ -81,10 +82,12 @@ VERBOSE=0
WAIT_VERBOSE_LOG_FILE=""
WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}"
WAIT_VERBOSE_TAIL_LINE_COUNT="${WAIT_VERBOSE_TAIL_LINE_COUNT:-40}"
HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-0.2}"
WAIT_VERBOSE_LAST_LOG_FILE=""
WAIT_VERBOSE_EMITTED_LINES=0
HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-2}"
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-60}"
BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}"
DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}"
DEPENDENCY_INSTALL_RETRY_INTERVAL="${DEPENDENCY_INSTALL_RETRY_INTERVAL:-5}"
@@ -94,6 +97,8 @@ AI_PROVIDER_START_MAX_RETRIES="${AI_PROVIDER_START_MAX_RETRIES:-3}"
AI_PROVIDER_RETRY_INTERVAL="${AI_PROVIDER_RETRY_INTERVAL:-5}"
DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}"
DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}"
DATABASE_HEALTH_CHECK_ATTEMPTS="${DATABASE_HEALTH_CHECK_ATTEMPTS:-10}"
DATABASE_HEALTH_CHECK_INTERVAL="${DATABASE_HEALTH_CHECK_INTERVAL:-$DATABASE_RETRY_INTERVAL}"
FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}"
FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}"
FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}"
@@ -107,16 +112,27 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
DEFAULT_MOTION_AGENT_PORT="${DEFAULT_MOTION_AGENT_PORT:-8765}"
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
PLANET_STATE_DIR="${PLANET_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/planet}"
PLANET_CACHE_DIR="${PLANET_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/planet}"
BACKEND_PID_FILE="$PLANET_STATE_DIR/backend.pid"
BACKEND_LOG_FILE="$PLANET_STATE_DIR/backend.log"
FRONTEND_PID_FILE="$PLANET_STATE_DIR/frontend.pid"
FRONTEND_LOG_FILE="$PLANET_STATE_DIR/frontend.log"
PLANET_PORT_STATE_FILE="$PLANET_STATE_DIR/ports.env"
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
MOTION_AGENT_PID_FILE="/tmp/planet_motion_agent.pid"
MOTION_AGENT_LOG_FILE="/tmp/planet_motion_agent.log"
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid"
MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log"
AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="$PLANET_STATE_DIR/aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}"
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-/tmp/planet_aiprovider_runtime.env}"
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-$PLANET_STATE_DIR/aiprovider_runtime.env}"
AI_PROVIDER_RECREATE_REQUIRED=0
START_RUN_ACTIVE=0
START_RUN_COMPLETED=0
STARTED_BACKEND_THIS_RUN=0
STARTED_FRONTEND_THIS_RUN=0
STARTED_MOTION_AGENT_THIS_RUN=0
AI_PROVIDER_RUNTIME_ENV_NAMES=(
SERVICE_NAME
SERVICE_VERSION
@@ -133,6 +149,9 @@ AI_PROVIDER_RUNTIME_ENV_NAMES=(
AI_PROVIDER_SERVICE_TOKEN
)
mkdir -p "$PLANET_STATE_DIR" "$PLANET_CACHE_DIR"
chmod 700 "$PLANET_STATE_DIR" "$PLANET_CACHE_DIR" 2>/dev/null || true
prepend_path_once() {
local path_entry="$1"
[ -n "$path_entry" ] || return 0
@@ -152,6 +171,98 @@ ensure_local_runtime_bins_on_path() {
ensure_local_runtime_bins_on_path
# Shell / Docker helpers
is_pid() {
[[ "${1:-}" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ]
}
is_safe_signal() {
case "${1:-}" in
TERM|KILL|INT|HUP)
return 0
;;
*)
return 1
;;
esac
}
read_pid_file() {
local pid_file="$1"
local pid=""
[ -f "$pid_file" ] || return 1
pid="$(head -n 1 "$pid_file" 2>/dev/null | tr -d '[:space:]' || true)"
is_pid "$pid" || return 1
printf "%s\n" "$pid"
}
write_pid_file() {
local pid_file="$1"
local pid="$2"
is_pid "$pid" || return 1
mkdir -p "$(dirname "$pid_file")"
printf "%s\n" "$pid" > "$pid_file"
chmod 600 "$pid_file" 2>/dev/null || true
}
remove_pid_file() {
local pid_file="$1"
rm -f "$pid_file"
}
http_ok() {
local url="$1"
curl -fsS --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1
}
log_matches() {
local log_file="$1"
shift
[ -f "$log_file" ] || return 1
[ "$#" -gt 0 ] || return 1
grep -Eiq "$*" "$log_file" 2>/dev/null
}
read_port_state_value() {
local key="$1"
local fallback="$2"
local value=""
if [ -f "$PLANET_PORT_STATE_FILE" ]; then
value="$(sed -nE "s/^${key}=([0-9]+)$/\\1/p" "$PLANET_PORT_STATE_FILE" | tail -n 1)"
fi
if [ -n "$value" ]; then
printf "%s" "$value"
else
printf "%s" "$fallback"
fi
}
write_port_state() {
local backend_port="$1"
local frontend_port="$2"
local ai_provider_port="$3"
local motion_agent_port="$4"
validate_port "$backend_port"
validate_port "$frontend_port"
validate_port "$ai_provider_port"
validate_port "$motion_agent_port"
{
printf "BACKEND_PORT=%s\n" "$backend_port"
printf "FRONTEND_PORT=%s\n" "$frontend_port"
printf "AI_PROVIDER_PORT=%s\n" "$ai_provider_port"
printf "MOTION_AGENT_PORT=%s\n" "$motion_agent_port"
} > "$PLANET_PORT_STATE_FILE"
chmod 600 "$PLANET_PORT_STATE_FILE" 2>/dev/null || true
}
compose_available() {
docker compose version >/dev/null 2>&1
}
@@ -253,36 +364,10 @@ render_wait_spinner() {
local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} ))
local frame="${WAIT_SPINNER_FRAMES[$frame_index]}"
local detail="${WAIT_SPINNER_DETAIL:- }"
local verbose_line=""
local verbose_lines=()
local line_count=0
local cursor_up=1
if [ "$VERBOSE" -eq 1 ] && [ -n "$WAIT_VERBOSE_LOG_FILE" ] && [ -f "$WAIT_VERBOSE_LOG_FILE" ]; then
while IFS= read -r verbose_line; do
verbose_line="$(sanitize_wait_detail "$verbose_line")"
[ -n "$verbose_line" ] || continue
verbose_lines+=("$verbose_line")
done < <(tail -n "$WAIT_VERBOSE_TAIL_LINE_COUNT" "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null)
while [ "${#verbose_lines[@]}" -gt "$WAIT_VERBOSE_LINE_COUNT" ]; do
verbose_lines=("${verbose_lines[@]:1}")
done
fi
flush_wait_verbose_log
printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2
if [ "$VERBOSE" -eq 1 ]; then
line_count=1
while [ "$line_count" -le "$WAIT_VERBOSE_LINE_COUNT" ]; do
verbose_line="${verbose_lines[$line_count]:- }"
printf "\n${DIM} %s${NC}\033[K" "$verbose_line" >&2
line_count=$((line_count + 1))
done
cursor_up=$((WAIT_VERBOSE_LINE_COUNT + 1))
fi
printf "\033[%sA\r" "$cursor_up" >&2
printf "\033[1A\r" >&2
}
animate_wait_spinner() {
@@ -305,11 +390,6 @@ animate_wait_spinner() {
clear_wait_spinner() {
local line_count=0
local clear_verbose_lines=0
if [ "$VERBOSE" -eq 1 ] && { [ "$WAIT_SESSION_ACTIVE" -eq 1 ] || [ -n "$WAIT_VERBOSE_LOG_FILE" ]; }; then
clear_verbose_lines=1
fi
printf "\r\033[K" >&2
line_count=0
@@ -318,15 +398,8 @@ clear_wait_spinner() {
line_count=$((line_count + 1))
done
if [ "$clear_verbose_lines" -eq 1 ]; then
line_count=0
while [ "$line_count" -lt "$WAIT_VERBOSE_LINE_COUNT" ]; do
printf "\n\033[K" >&2
line_count=$((line_count + 1))
done
fi
printf "\033[%sA\r" "$((clear_verbose_lines == 1 ? WAIT_VERBOSE_LINE_COUNT + 1 : 1))" >&2
printf "\033[1A\r" >&2
flush_wait_verbose_log
}
start_wait_session() {
@@ -366,6 +439,8 @@ reset_wait_spinner_state() {
WAIT_SPINNER_DETAIL=""
WAIT_SPINNER_MESSAGE=""
WAIT_VERBOSE_LOG_FILE=""
WAIT_VERBOSE_LAST_LOG_FILE=""
WAIT_VERBOSE_EMITTED_LINES=0
}
sanitize_wait_detail() {
@@ -382,6 +457,39 @@ sanitize_wait_detail() {
printf "%s" "$line"
}
flush_wait_verbose_log() {
local line=""
local line_number=0
local total_lines=0
[ "$VERBOSE" -eq 1 ] || return 0
[ -n "$WAIT_VERBOSE_LOG_FILE" ] || return 0
[ -f "$WAIT_VERBOSE_LOG_FILE" ] || return 0
if [ "$WAIT_VERBOSE_LAST_LOG_FILE" != "$WAIT_VERBOSE_LOG_FILE" ]; then
WAIT_VERBOSE_LAST_LOG_FILE="$WAIT_VERBOSE_LOG_FILE"
WAIT_VERBOSE_EMITTED_LINES=0
fi
total_lines="$(wc -l < "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null | tr -d '[:space:]')"
[ -n "$total_lines" ] || total_lines=0
if [ "$total_lines" -lt "$WAIT_VERBOSE_EMITTED_LINES" ]; then
WAIT_VERBOSE_EMITTED_LINES=0
fi
[ "$total_lines" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || return 0
printf "\r\033[K" >&2
while IFS= read -r line; do
line_number=$((line_number + 1))
[ "$line_number" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || continue
line="$(sanitize_wait_detail "$line")"
[ -n "$line" ] || continue
printf "${DIM} %s${NC}\n" "$line" >&2
done < "$WAIT_VERBOSE_LOG_FILE"
WAIT_VERBOSE_EMITTED_LINES="$total_lines"
}
set_wait_detail() {
WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")"
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
@@ -400,7 +508,7 @@ run_command_with_spinner() {
fi
if [ "$VERBOSE" -eq 1 ]; then
verbose_log_file="$(mktemp /tmp/planet_verbose.XXXXXX.log)"
verbose_log_file="$(mktemp "$PLANET_STATE_DIR/verbose.XXXXXX.log")"
WAIT_VERBOSE_LOG_FILE="$verbose_log_file"
"$@" > "$verbose_log_file" 2>&1 &
else
@@ -494,7 +602,13 @@ log_error() {
}
log_note() {
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
clear_wait_spinner
fi
printf "${DIM} %s${NC}\n" "$1"
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
resume_wait_session
fi
}
log_success() {
@@ -704,7 +818,7 @@ remove_ai_provider_containers() {
local container_ids
container_ids="$(list_ai_provider_containers)"
[ -n "$container_ids" ] || return 0
echo "$container_ids" | xargs -r docker rm -f >/dev/null 2>&1 || true
printf "%s\n" "$container_ids" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
}
run_ai_provider_container_manually() {
@@ -1086,7 +1200,7 @@ wait_for_http() {
set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})"
while [ "$attempt" -le "$attempts" ]; do
if curl -s --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1; then
if http_ok "$url"; then
finish_wait_spinner "${service_name}已就绪"
return 0
fi
@@ -1179,7 +1293,7 @@ wait_for_frontend_ready() {
fi
if [ $(( tick % probe_every_ticks )) -eq 0 ] &&
curl -s --max-time "$HTTP_CHECK_MAX_TIME" "http://localhost:${frontend_port}" > /dev/null 2>&1; then
http_ok "http://localhost:${frontend_port}"; then
if [ "$owns_wait_session" -eq 1 ]; then
stop_wait_session
finish_wait_spinner "前端已就绪"
@@ -1289,6 +1403,7 @@ force_cleanup_external_port_listener() {
local saw_no_windows_listener=0
local saw_cleanup_failure=0
validate_port "$port"
is_wsl_environment || return 1
command -v powershell.exe >/dev/null 2>&1 || return 1
@@ -1378,12 +1493,12 @@ EOF
# Service startup helpers
wait_for_database_health() {
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" &&
wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis"
wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "PostgreSQL" &&
wait_for_container_health "planet_redis" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "Redis"
}
wait_for_postgres_health() {
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL"
wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "PostgreSQL"
}
start_database_services() {
@@ -1401,6 +1516,19 @@ start_postgres_service() {
# Backend lifecycle helpers
cleanup_backend_processes() {
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
local tracked_pid=""
if [ -f "$BACKEND_PID_FILE" ]; then
if tracked_pid="$(read_pid_file "$BACKEND_PID_FILE")"; then
terminate_process_group TERM "$tracked_pid"
terminate_process_tree TERM "$tracked_pid"
sleep 1
terminate_process_group KILL "$tracked_pid"
terminate_process_tree KILL "$tracked_pid"
fi
remove_pid_file "$BACKEND_PID_FILE"
fi
terminate_backend_processes TERM "$backend_port"
if ! wait_for_port_release "$backend_port" 15 0.2; then
@@ -1424,11 +1552,12 @@ start_backend_with_retry() {
fail_unreleased_port "$backend_port"
fi
cd "$SCRIPT_DIR/backend"
: > /tmp/planet_backend.log
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
: > "$BACKEND_LOG_FILE"
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > "$BACKEND_LOG_FILE" 2>&1 &
BACKEND_PID=$!
write_pid_file "$BACKEND_PID_FILE" "$BACKEND_PID"
if [ "$VERBOSE" -eq 1 ]; then
WAIT_VERBOSE_LOG_FILE="/tmp/planet_backend.log"
WAIT_VERBOSE_LOG_FILE="$BACKEND_LOG_FILE"
fi
if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
@@ -1436,14 +1565,14 @@ start_backend_with_retry() {
fi
kill "$BACKEND_PID" 2>/dev/null || true
if backend_log_indicates_port_conflict "/tmp/planet_backend.log"; then
if backend_log_indicates_port_conflict "$BACKEND_LOG_FILE"; then
cleanup_backend_processes "$backend_port" || fail_unreleased_port "$backend_port"
if wait_for_port_release "$backend_port" 5 0.2; then
log_warn "后端端口冲突已清理,立即重试启动"
retry=$((retry + 1))
continue
fi
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log"
report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE"
return 1
fi
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
@@ -1451,7 +1580,7 @@ start_backend_with_retry() {
done
clear_wait_spinner
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" || true
report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE" || true
return 1
}
@@ -1500,8 +1629,7 @@ ai_provider_service_healthy() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
curl -s --max-time "$HTTP_CHECK_MAX_TIME" \
"http://localhost:${ai_provider_port}/health" >/dev/null 2>&1
http_ok "http://localhost:${ai_provider_port}/health"
}
ensure_database_services_healthy() {
@@ -1596,7 +1724,7 @@ start_backend_service() {
ensure_uv_backend_deps
if ! start_backend_with_retry "$backend_port"; then
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次"
tail -10 /tmp/planet_backend.log
tail -10 "$BACKEND_LOG_FILE"
exit 1
fi
}
@@ -1650,11 +1778,12 @@ terminate_process_group() {
local pid="$2"
local pgid=""
[ -n "$pid" ] || return 0
is_safe_signal "$signal" || return 1
is_pid "$pid" || return 0
kill -0 "$pid" 2>/dev/null || return 0
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
[ -n "$pgid" ] || return 0
is_pid "$pgid" || return 0
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
}
@@ -1665,7 +1794,7 @@ terminate_backend_processes() {
local pids=""
local pid=""
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)"
pids="$(pgrep -f "uvicorn .*--port ${backend_port}" 2>/dev/null || true)"
for pid in $pids; do
terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid"
@@ -1683,12 +1812,13 @@ terminate_process_tree() {
local pid="$2"
local child_pid
[ -n "$pid" ] || return 0
is_safe_signal "$signal" || return 1
is_pid "$pid" || return 0
kill -0 "$pid" 2>/dev/null || return 0
if command -v pgrep >/dev/null 2>&1; then
while IFS= read -r child_pid; do
[ -n "$child_pid" ] || continue
is_pid "$child_pid" || continue
terminate_process_tree "$signal" "$child_pid"
done < <(pgrep -P "$pid" 2>/dev/null || true)
fi
@@ -1821,15 +1951,13 @@ kill_port_if_requested() {
frontend_log_indicates_port_conflict() {
local log_file="$1"
[ -f "$log_file" ] || return 1
grep -q "Port .* is already in use" "$log_file" 2>/dev/null
log_matches "$log_file" "Port .* is already in use"
}
backend_log_indicates_port_conflict() {
local log_file="$1"
[ -f "$log_file" ] || return 1
grep -Eiq "Address already in use|Errno 98" "$log_file" 2>/dev/null
log_matches "$log_file" "Address already in use|Errno 98"
}
print_windows_port_listener_details() {
@@ -1927,18 +2055,16 @@ cleanup_frontend_processes() {
local tracked_pid=""
if [ -f "$FRONTEND_PID_FILE" ]; then
tracked_pid="$(cat "$FRONTEND_PID_FILE" 2>/dev/null || true)"
if [ -n "$tracked_pid" ]; then
if tracked_pid="$(read_pid_file "$FRONTEND_PID_FILE")"; then
terminate_process_tree TERM "$tracked_pid"
sleep 1
terminate_process_tree KILL "$tracked_pid"
fi
rm -f "$FRONTEND_PID_FILE"
remove_pid_file "$FRONTEND_PID_FILE"
fi
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
}
start_frontend_with_retry() {
@@ -1954,25 +2080,25 @@ start_frontend_with_retry() {
log_warn "前端端口 ${frontend_port} 预清理后仍需由 Vite 启动流程确认"
fi
cd "$SCRIPT_DIR/frontend"
: > /tmp/planet_frontend.log
: > "$FRONTEND_LOG_FILE"
local -a frontend_args
frontend_args=("$FRONTEND_VITE_ENTRY")
if [ "$frontend_lan_enabled" -eq 1 ]; then
frontend_args+=(--host 0.0.0.0)
fi
frontend_args+=(--port "$frontend_port" --strictPort)
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 &
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > "$FRONTEND_LOG_FILE" 2>&1 &
FRONTEND_PID=$!
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE"
write_pid_file "$FRONTEND_PID_FILE" "$FRONTEND_PID"
if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "/tmp/planet_frontend.log" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then
if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "$FRONTEND_LOG_FILE" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then
return 0
fi
cleanup_frontend_processes "$frontend_port"
reset_wait_spinner_state
if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "/tmp/planet_frontend.log"; then
if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "$FRONTEND_LOG_FILE"; then
kill_port_if_requested "$frontend_port" "前端"
fi
@@ -2007,7 +2133,8 @@ start_frontend_service() {
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
stop_wait_session
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
tail -10 /tmp/planet_frontend.log
tail -10 "$FRONTEND_LOG_FILE"
print_port_listener_details "$frontend_port"
exit 1
fi
stop_wait_session
@@ -2139,7 +2266,7 @@ cleanup_exit_containers() {
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
if [ -n "$exit_containers" ]; then
log_step "清理残留 Exit 容器"
echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
printf "%s\n" "$exit_containers" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
log_success "残留容器已清理"
fi
}
@@ -2157,7 +2284,7 @@ stop_container_if_running() {
}
stop_backend_service() {
if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
if pgrep -f "uvicorn .*--port ${DEFAULT_BACKEND_PORT}" >/dev/null 2>&1 || [ -f "$BACKEND_PID_FILE" ] || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
log_halt "后端服务已停止"
fi
@@ -2168,7 +2295,7 @@ stop_ai_provider_service() {
}
stop_frontend_service() {
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
if pgrep -f "${FRONTEND_VITE_ENTRY} .*--port ${DEFAULT_FRONTEND_PORT}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
cleanup_frontend_processes "$FRONTEND_PORT"
@@ -2183,11 +2310,39 @@ stop_frontend_service() {
fi
}
print_http_health_status() {
local service_name="$1"
local url="$2"
if http_ok "$url"; then
echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}"
fi
}
print_process_health_status() {
local service_name="$1"
local pattern="$2"
local pid_file="${3:-}"
local tracked_pid=""
if [ -n "$pid_file" ]; then
tracked_pid="$(read_pid_file "$pid_file" || true)"
fi
if pgrep -f "$pattern" >/dev/null 2>&1 || { [ -n "$tracked_pid" ] && kill -0 "$tracked_pid" 2>/dev/null; }; then
echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}"
fi
}
motion_agent_pids() {
local pids=""
if [ -f "$MOTION_AGENT_PID_FILE" ]; then
pids="$(cat "$MOTION_AGENT_PID_FILE" 2>/dev/null || true)"
pids="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)"
if [ -n "$pids" ] && kill -0 "$pids" 2>/dev/null; then
printf "%s\n" "$pids"
fi
@@ -2206,7 +2361,7 @@ cleanup_motion_agent_processes() {
terminate_process_tree "$signal" "$pid"
done < <(motion_agent_pids | awk '!seen[$0]++')
rm -f "$MOTION_AGENT_PID_FILE"
remove_pid_file "$MOTION_AGENT_PID_FILE"
}
wait_for_motion_agent_ready() {
@@ -2298,7 +2453,7 @@ start_motion_agent_service() {
cd "$SCRIPT_DIR"
nohup "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}" > "$MOTION_AGENT_LOG_FILE" 2>&1 &
MOTION_AGENT_PID=$!
printf "%s" "$MOTION_AGENT_PID" > "$MOTION_AGENT_PID_FILE"
write_pid_file "$MOTION_AGENT_PID_FILE" "$MOTION_AGENT_PID"
if wait_for_motion_agent_ready "$motion_agent_port" "$MOTION_AGENT_PID"; then
log_success "Motion Agent 已就绪"
@@ -2308,6 +2463,7 @@ start_motion_agent_service() {
log_error "Motion Agent 启动失败"
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || true
print_port_listener_details "$motion_agent_port"
log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。"
log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。"
log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。"
@@ -2323,6 +2479,24 @@ stop_motion_agent_service() {
fi
}
cleanup_failed_start() {
[ "$START_RUN_ACTIVE" -eq 1 ] || return 0
[ "$START_RUN_COMPLETED" -eq 0 ] || return 0
clear_wait_spinner
log_warn "启动未完成,清理本轮已拉起的本地进程"
if [ "$STARTED_MOTION_AGENT_THIS_RUN" -eq 1 ]; then
cleanup_motion_agent_processes TERM
fi
if [ "$STARTED_FRONTEND_THIS_RUN" -eq 1 ]; then
cleanup_frontend_processes "${FRONTEND_PORT:-$DEFAULT_FRONTEND_PORT}"
fi
if [ "$STARTED_BACKEND_THIS_RUN" -eq 1 ]; then
cleanup_backend_processes "${BACKEND_PORT:-$DEFAULT_BACKEND_PORT}" || true
fi
}
create_user() {
local username
local password
@@ -2433,14 +2607,27 @@ start() {
parse_service_args "$@"
cleanup_exit_containers
START_RUN_ACTIVE=1
START_RUN_COMPLETED=0
STARTED_BACKEND_THIS_RUN=0
STARTED_FRONTEND_THIS_RUN=0
STARTED_MOTION_AGENT_THIS_RUN=0
print_splash
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
STARTED_BACKEND_THIS_RUN=1
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
STARTED_FRONTEND_THIS_RUN=1
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
STARTED_MOTION_AGENT_THIS_RUN=1
fi
write_port_state "$BACKEND_PORT" "$FRONTEND_PORT" "$AI_PROVIDER_PORT" "$MOTION_AGENT_PORT"
START_RUN_COMPLETED=1
START_RUN_ACTIVE=0
log_success "启动完成"
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
@@ -2468,6 +2655,10 @@ stop() {
restart() {
parse_service_args "$@"
cleanup_exit_containers
local state_backend_port=""
local state_frontend_port=""
local state_ai_provider_port=""
local state_motion_agent_port=""
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop
@@ -2506,6 +2697,24 @@ restart() {
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
fi
state_backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")"
state_frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")"
state_ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")"
state_motion_agent_port="$(read_port_state_value MOTION_AGENT_PORT "$DEFAULT_MOTION_AGENT_PORT")"
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
state_backend_port="$BACKEND_PORT"
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
state_frontend_port="$FRONTEND_PORT"
fi
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
state_ai_provider_port="$AI_PROVIDER_PORT"
fi
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
state_motion_agent_port="$MOTION_AGENT_PORT"
fi
write_port_state "$state_backend_port" "$state_frontend_port" "$state_ai_provider_port" "$state_motion_agent_port"
echo ""
log_success "重启完成"
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
@@ -2529,34 +2738,23 @@ restart() {
}
health() {
local backend_port=""
local frontend_port=""
local ai_provider_port=""
backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")"
frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")"
ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")"
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}"
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || log_note "Docker daemon 不可用,跳过容器状态"
echo ""
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}"
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} 后端:${NC} ${RED}offline${NC}"
fi
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}"
fi
if curl -s http://localhost:3000 > /dev/null 2>&1; then
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
fi
if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || [ -f "$MOTION_AGENT_PID_FILE" ]; then
echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}"
fi
print_http_health_status "后端" "http://localhost:${backend_port}/health"
print_http_health_status "AI Provider" "http://localhost:${ai_provider_port}/health"
print_http_health_status "前端" "http://localhost:${frontend_port}"
print_process_health_status "Motion Agent" "python.*-m motion_agent" "$MOTION_AGENT_PID_FILE"
}
log() {
@@ -2564,12 +2762,12 @@ log() {
-f|--frontend)
log_step "查看前端日志"
log_note "按 Ctrl+C 退出"
tail -f /tmp/planet_frontend.log
tail -f "$FRONTEND_LOG_FILE"
;;
-b|--backend)
log_step "查看后端日志"
log_note "按 Ctrl+C 退出"
tail -f /tmp/planet_backend.log
tail -f "$BACKEND_LOG_FILE"
;;
-a|--ai-provider)
log_step "查看 AI Provider 日志"
@@ -2584,11 +2782,11 @@ log() {
*)
log_step "查看最近日志"
log_note "后端"
tail -20 /tmp/planet_backend.log 2>/dev/null || log_note "无日志"
tail -20 "$BACKEND_LOG_FILE" 2>/dev/null || log_note "无日志"
log_note "AI Provider"
docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志"
log_note "前端"
tail -20 /tmp/planet_frontend.log 2>/dev/null || log_note "无日志"
tail -20 "$FRONTEND_LOG_FILE" 2>/dev/null || log_note "无日志"
log_note "Motion Agent"
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || log_note "无日志"
;;
@@ -2611,6 +2809,8 @@ parse_global_args() {
GLOBAL_ARG_REMAINDER=("$@")
}
trap cleanup_failed_start EXIT
parse_global_args "$@"
set -- "${GLOBAL_ARG_REMAINDER[@]}"
@@ -2637,7 +2837,7 @@ case "$1" in
;;
*)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "全局参数: -v, --verbose 在当前执行行下方滚动显示最多 5 行命令输出"
log_note "全局参数: -v, --verbose 在状态提示之间增量输出命令日志"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose"
log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose"