mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
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:
3
Makefile
3
Makefile
@@ -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)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user