fix(self-host): apply setup config to daemon (MUL-5269) (#5880)

Fixes two connected self-host setup failures in local worktree development.

Generated worktree environments now expose the backend HTTP origin through
MULTICA_PUBLIC_URL, and existing generated worktrees derive the missing value
at startup through both scripts/local-env.sh and the Makefile. Explicit
values, including an intentionally empty same-origin setting, are preserved.

setup and setup self-host now reconcile an existing same-profile daemon after
authentication so it loads the newly written server URL and token. An idle
daemon is restarted behind the existing restart preflight; when active tasks
exist, setup leaves the daemon running to avoid cancelling work and prints an
actionable profile-aware restart command instead.

Closes #5879
This commit is contained in:
YYClaw
2026-07-30 15:10:40 +08:00
committed by GitHub
parent 9c732c47c5
commit ee7ba83f53
6 changed files with 208 additions and 8 deletions

View File

@@ -13,6 +13,9 @@ POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
ifeq ($(origin MULTICA_PUBLIC_URL), undefined)
MULTICA_PUBLIC_URL := http://localhost:$(PORT)
endif
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)

View File

@@ -34,6 +34,7 @@ PORT=${backend_port}
JWT_SECRET=change-me-in-production
MULTICA_DEV_VERIFICATION_CODE=888888
MULTICA_SERVER_URL=ws://localhost:${backend_port}/ws
MULTICA_PUBLIC_URL=http://localhost:${backend_port}
MULTICA_APP_URL=${frontend_origin}
GOOGLE_CLIENT_ID=

View File

@@ -8,6 +8,12 @@ PORT="${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT}}"
# Older generated worktree env files predate MULTICA_PUBLIC_URL. Derive it
# only when the variable is absent; an explicitly configured value, including
# an intentionally empty one for same-origin proxying, must be preserved.
if [ "${MULTICA_PUBLIC_URL+x}" != "x" ]; then
MULTICA_PUBLIC_URL="http://localhost:${PORT}"
fi
MULTICA_APP_URL="${MULTICA_APP_URL:-${FRONTEND_ORIGIN}}"
GOOGLE_REDIRECT_URI="${GOOGLE_REDIRECT_URI:-${FRONTEND_ORIGIN}/auth/callback}"
MULTICA_SERVER_URL="${MULTICA_SERVER_URL:-ws://localhost:${PORT}/ws}"
@@ -16,5 +22,5 @@ PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-${FRONTEND_ORIGIN}}"
export POSTGRES_DB POSTGRES_USER POSTGRES_PORT
export PORT FRONTEND_PORT FRONTEND_ORIGIN
export MULTICA_APP_URL GOOGLE_REDIRECT_URI MULTICA_SERVER_URL LOCAL_UPLOAD_BASE_URL
export MULTICA_PUBLIC_URL MULTICA_APP_URL GOOGLE_REDIRECT_URI MULTICA_SERVER_URL LOCAL_UPLOAD_BASE_URL
export PLAYWRIGHT_BASE_URL

View File

@@ -29,7 +29,8 @@ require_env() {
}
tmp_env="$(mktemp)"
trap 'rm -f "$tmp_env"' EXIT
tmp_dir="$(mktemp -d)"
trap 'rm -f "$tmp_env"; rm -rf "$tmp_dir"' EXIT
sed 's/^FRONTEND_PORT=.*/FRONTEND_PORT=3100/' .env.example >"$tmp_env"
printf '\nBACKEND_PORT=9100\n' >>"$tmp_env"
@@ -84,4 +85,59 @@ require_env "$local_env" 'MULTICA_SERVER_URL=ws://localhost:9100/ws'
require_env "$local_env" 'LOCAL_UPLOAD_BASE_URL=http://localhost:9100'
require_env "$local_env" 'PLAYWRIGHT_BASE_URL=http://localhost:3100'
worktree_env="$tmp_dir/.env.worktree"
WORKTREE_NAME=selfhost-config-test bash scripts/init-worktree-env.sh "$worktree_env" >/dev/null
worktree_backend_port="$(sed -n 's/^PORT=//p' "$worktree_env")"
require_env "$(cat "$worktree_env")" "MULTICA_PUBLIC_URL=http://localhost:${worktree_backend_port}"
resolve_local_public_url() {
env -i PATH="$PATH" bash -c '
set -euo pipefail
env_file=$1
set -a
# shellcheck disable=SC1090
. "$env_file"
set +a
# shellcheck disable=SC1091
. scripts/local-env.sh
printf "%s\n" "$MULTICA_PUBLIC_URL"
' _ "$1"
}
make_env_probe="$tmp_dir/print-public-url.mk"
printf '%s\n' \
'.PHONY: print-public-url' \
'print-public-url:' \
' @printf "%s\n" "$$MULTICA_PUBLIC_URL"' \
>"$make_env_probe"
resolve_make_public_url() {
make \
--no-print-directory \
-s \
-f Makefile \
-f "$make_env_probe" \
ENV_FILE="$1" \
print-public-url
}
old_worktree_env="$tmp_dir/.env.worktree.old"
grep -v '^MULTICA_PUBLIC_URL=' "$worktree_env" >"$old_worktree_env"
require_env \
"$(resolve_local_public_url "$old_worktree_env")" \
"http://localhost:${worktree_backend_port}"
require_env \
"$(resolve_make_public_url "$old_worktree_env")" \
"http://localhost:${worktree_backend_port}"
explicit_worktree_env="$tmp_dir/.env.worktree.explicit"
cp "$old_worktree_env" "$explicit_worktree_env"
printf '\nMULTICA_PUBLIC_URL=https://api.explicit.example\n' >>"$explicit_worktree_env"
require_env \
"$(resolve_local_public_url "$explicit_worktree_env")" \
"https://api.explicit.example"
require_env \
"$(resolve_make_public_url "$explicit_worktree_env")" \
"https://api.explicit.example"
echo "self-host env derivation ok"

View File

@@ -169,9 +169,8 @@ func runSetupCloud(cmd *cobra.Command, args []string) error {
return err
}
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
if err := runDaemonAfterSetup(cmd, args); err != nil {
return fmt.Errorf("start or restart daemon: %w", err)
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
@@ -248,15 +247,83 @@ func runSetupSelfHost(cmd *cobra.Command, args []string) error {
return err
}
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
if err := runDaemonBackground(cmd); err != nil {
return fmt.Errorf("start daemon: %w", err)
if err := runDaemonAfterSetup(cmd, args); err != nil {
return fmt.Errorf("start or restart daemon: %w", err)
}
fmt.Fprintln(os.Stderr, "\n✓ Setup complete! Your machine is now connected to Multica.")
return nil
}
// runDaemonAfterSetup makes the freshly authenticated profile effective in
// the daemon process. A daemon reads its server URL and token only at startup,
// so merely overwriting config.json while one is already running leaves it
// connected to the previous deployment. Restart that profile when needed;
// otherwise perform the normal first start.
func runDaemonAfterSetup(cmd *cobra.Command, args []string) error {
profile := resolveProfile(cmd)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
health := checkDaemonHealthOnPort(ctx, healthPortForProfile(profile))
return dispatchDaemonAfterSetup(
cmd,
args,
health,
func(cmd *cobra.Command, _ []string) error {
return runDaemonBackground(cmd)
},
runDaemonRestart,
)
}
type setupDaemonRunner func(*cobra.Command, []string) error
func dispatchDaemonAfterSetup(
cmd *cobra.Command,
args []string,
health map[string]any,
start setupDaemonRunner,
restart setupDaemonRunner,
) error {
if daemonAlive(health) {
if activeTasks := daemonActiveTaskCount(health); activeTasks > 0 {
taskLabel := "tasks"
if activeTasks == 1 {
taskLabel = "task"
}
restartCmd := "multica daemon restart"
if profile := resolveProfile(cmd); profile != "" {
restartCmd += " --profile " + profile
}
return fmt.Errorf(
"daemon has %d active %s; setup saved the new configuration but left the running daemon unchanged to avoid cancelling work. Wait for the active work to finish, then run '%s' to apply the new configuration",
activeTasks,
taskLabel,
restartCmd,
)
}
fmt.Fprintln(os.Stderr, "\nRestarting daemon to apply the new configuration...")
return restart(cmd, args)
}
fmt.Fprintln(os.Stderr, "\nStarting daemon...")
return start(cmd, args)
}
func daemonActiveTaskCount(health map[string]any) int64 {
switch count := health["active_task_count"].(type) {
case float64:
return int64(count)
case int:
return int64(count)
case int64:
return count
default:
return 0
}
}
// persistSelfHostConfigIfReachable probes serverURL and, only when it answers,
// overwrites the profile config with the given self-host URLs. When the server
// is unreachable it leaves any existing config — and its auth token — untouched

View File

@@ -74,6 +74,73 @@ func TestPersistSelfHostConfigIfReachable(t *testing.T) {
})
}
func TestDispatchDaemonAfterSetup(t *testing.T) {
newRunner := func(calls *[]string, name string) func(*cobra.Command, []string) error {
return func(*cobra.Command, []string) error {
*calls = append(*calls, name)
return nil
}
}
t.Run("restarts an existing daemon so new config takes effect", func(t *testing.T) {
var calls []string
err := dispatchDaemonAfterSetup(
&cobra.Command{},
nil,
map[string]any{"status": "running"},
newRunner(&calls, "start"),
newRunner(&calls, "restart"),
)
if err != nil {
t.Fatalf("dispatchDaemonAfterSetup: %v", err)
}
if got := strings.Join(calls, ","); got != "restart" {
t.Fatalf("calls = %q, want restart", got)
}
})
t.Run("starts when no daemon is running", func(t *testing.T) {
var calls []string
err := dispatchDaemonAfterSetup(
&cobra.Command{},
nil,
map[string]any{"status": "stopped"},
newRunner(&calls, "start"),
newRunner(&calls, "restart"),
)
if err != nil {
t.Fatalf("dispatchDaemonAfterSetup: %v", err)
}
if got := strings.Join(calls, ","); got != "start" {
t.Fatalf("calls = %q, want start", got)
}
})
t.Run("does not restart while tasks are active", func(t *testing.T) {
var calls []string
err := dispatchDaemonAfterSetup(
&cobra.Command{},
nil,
map[string]any{
"status": "running",
"active_task_count": float64(2),
},
newRunner(&calls, "start"),
newRunner(&calls, "restart"),
)
if err == nil {
t.Fatal("dispatchDaemonAfterSetup: want active-task error")
}
if !strings.Contains(err.Error(), "2 active tasks") ||
!strings.Contains(err.Error(), "multica daemon restart") {
t.Fatalf("error = %q, want active count and restart guidance", err)
}
if len(calls) != 0 {
t.Fatalf("calls = %v, want neither start nor restart", calls)
}
})
}
// TestResolveSelfHostServerURL covers GitHub #3912: `setup self-host` must
// honor MULTICA_SERVER_URL when --server-url is not passed, instead of always
// defaulting to localhost (which left self-hosters stuck on an "unreachable"