mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* fix(views): stop blaming the daemon when the backend is the stale half (MUL-6323) A self-hosted user on macOS Desktop could not pick worktree mode, and the dialog explained why: "the Multica on that machine is v0.4.28; parallel needs 0.4.24 or newer". v0.4.28 was the newest release at the time. There was no version left to upgrade to (#7113). Nothing has gated on a version since MUL-5707 moved all three gates onto the declared local-worktree-v1 capability. The floor survived only as the number this copy interpolated, so every capability miss — whatever caused it — was reported as a version shortfall the user had already cleared. Underneath was a second problem the copy could not express. The capability is recorded by the SERVER at registration, and servers before v0.4.25 write runtime metadata with no capabilities key at all. Desktop ships its own renderer AND its own daemon and updates both together, so the client routinely runs ahead of a backend the operator upgrades by hand: the daemon advertises the capability, the old backend drops it, and the UI blames the machine that was already newest. Upgrading it changes nothing. So the gate now answers with LocalWorktreeSupport instead of a boolean, and the discriminator is the PRESENCE of the capabilities key rather than its contents — a capability-aware server always writes it, null when the daemon sent no header, so a row without it can only have come from an older server. The two negatives get their own copy pointing at the half that can actually be fixed, and no message quotes a daemon version any more; the server's own 422 was rewritten the same way in #6904. Also records capabilities on runtime-profile registration failure rows. Those are written after the successful runtimes of the same request, so until the first heartbeat they are the newest row for the daemon — which is the row every worktree gate reads. A failed custom profile briefly made a capable daemon look incapable, and would now have made a current backend look stale. Removes localWorktreeSupported and MIN_LOCAL_WORKTREE_CLI_VERSION from the frontend: the first had no callers left, the second only fed the copy this change deletes. Co-authored-by: multica-agent <[email protected]> * fix(views): ask the live backend whether it records capabilities (MUL-6323) Review follow-up. The first pass read a missing `capabilities` key as "this backend predates the handshake", but the key's absence only proves the server was old WHEN THE ROW WAS WRITTEN. Upgrading the backend does not rewrite runtime metadata and heartbeats touch only last_seen_at, so an upgraded deployment would keep being told it was too old — and upgrading it again would change nothing. The daemon does not re-register on a timer, so that state can sit there indefinitely. The question is now asked of the server actually in front of us, via /api/config's server_version, and the stale-row case gets its own answer: both halves are fine, the machine just has to register again. An ABSENT version reads as capability-aware, which is the load-bearing default — /api/config omits the field on the managed cloud by design and leaves it empty for unstamped dev builds, while the field itself shipped in v0.4.2, 23 patches before the handshake. So every self-hosted release that could genuinely be blind still names itself, and cloud users are never told to upgrade a backend they do not run. Replaces the cross-side version-floor test rather than deleting the contract. It pinned MIN_LOCAL_WORKTREE_CLI_VERSION against a frontend twin that this branch removed, and the number it guarded is one the UI no longer shows. What the two sides must still agree on is the capability token: the daemon advertises that exact string, the server stores it, the UI looks for it, and a typo on either side disables worktree mode for everyone with no error anywhere. Also corrects a comment that claimed no version numbers appear in this copy — the backend floor does, deliberately, because it is what an operator upgrades past. Co-authored-by: multica-agent <[email protected]> * fix(views): stop assuming an unnamed backend records capabilities (MUL-6323) Review follow-up. Reading an absent server_version as "capability-aware" was right for two of its three sources and wrong for the third: a self-hosted server older than v0.4.2 — where the field itself shipped — reports no version AND predates the handshake, so it records no capabilities either. Such a deployment got told to restart Multica on the machine, which re-registered against the same blind server and wrote the same key-less row. A loop whose only real exit, upgrading the backend, the copy never mentioned. So awareness is three-valued now, and an unnamed server is resolved from evidence rather than assumption: if ANY runtime row in the workspace carries a capabilities key, some capability-aware server wrote it and servers move forward, so the deployment is aware whoever the row belongs to. That settles the managed-cloud shape — no version reported, several machines, one of them registered before the handshake — without a version to read. The converse is never inferred; every row missing the key is equally what an old self-hosted server and a one-machine workspace look like. When nothing identifies the backend the UI says so and names both remedies, still failing closed. Guessing wrong in either direction sends someone to upgrade a backend they do not run, or to restart a machine that was never the problem. Also rewrites the re-register copy, which explained itself in the vocabulary of the handshake rather than the user's. Co-authored-by: multica-agent <[email protected]> * refactor(views): let the server answer whether a machine can run worktree (MUL-6323) The client-side gate kept growing because it was trying to predict an answer the server already gives. Each round added a state: capability present or absent, then the backend's own version to interpret the absence, then evidence from other rows to interpret an unnamed backend. Three layers of inference over data the backend wrote, to avoid asking the backend. So it asks. The UI no longer blocks worktree mode on capability at all — only on `not_git`, which is local, certain and already checked by the picker. The mode stays selectable, the save is validated server-side, and the rejection lands in the dialog's existing errorMessage slot with the wording #6904 wrote for it. That deletes the awareness tri-state, the workspace-wide evidence scan, the server-version plumbing, three UI reasons and four copy strings in four languages. This also fixes the reported case outright rather than explaining it: a self-hosted backend older than the handshake runs the version gate it shipped with, which accepts a v0.4.28 daemon, so the user simply gets worktree mode instead of an instruction to upgrade something. The capability signal survives for one job — deciding whether to PRESELECT parallel mode — where being wrong costs a radio button rather than a blocked user. A machine that has not advertised is not preselected into a mode whose rejection would fail the whole project creation, but it stays selectable, because an old row is not proof the machine cannot. For any of that to be true the bundled create path had to actually ask: POST /api/projects attaches resources in one transaction and never ran the worktree gate that POST/PUT /resources have always run, so it would happily store a mode the machine cannot run and leave the claim gate to cancel the task later. It runs the same gate now, before the transaction opens. Co-authored-by: multica-agent <[email protected]> * fix: refuse worktree mode until the server says it will honour it (MUL-6323) Review caught a safety regression in the previous commit, and it is the worse half of this issue rather than a variant of it. Deferring to the server assumed the server would object. Releases before v0.4.25 do not: `validateLocalDirectoryRef` unmarshals the ref into a struct with no ExecutionMode field and re-marshals it, so `execution_mode: "worktree"` is silently DROPPED and answered 201. The daemon then reads a ref with no mode, treats that as in_place by design, and edits the working copy the user asked it to stay out of. No save gate, no claim gate, no error — on the exact new-Desktop-old-backend pairing this issue is about. My earlier claim that such a server would run its own version gate was wrong: worktree shipped in v0.4.25, so v0.4.24 has no concept of the mode at all. The client cannot tell that success from a real one, so it asks first. /api/config now carries local_worktree_supported, a property of the build: if this code is running, the save gate is running with it. Absent — which is what every too-old server sends — blocks the mode with copy naming the server, and the schema parses the missing key to false rather than undefined so a consumer cannot forget to coerce it. That keeps the simplification: one declared boolean from the live server, no inference over rows or version strings, and per-machine capability still left to the server's save-time gate. Also clears review nits: a comment still naming the removed localWorktreeSupport and stray blank runs in two test files. Co-authored-by: multica-agent <[email protected]> * fix(views): rename a local directory without resending its ref (MUL-6323) Review follow-up, and the same failure through a door the last commit left open. The client now refuses worktree mode when the server cannot promise to honour it — but renaming an EXISTING worktree resource sent the whole resource_ref back, capability check or not. The server replaces the ref with whatever it can parse. On a backend rolled back below v0.4.25 — documented as supported while the runtimes stay current — that means execution_mode is dropped and answered 200, and the next task edits the working copy the resource asked to isolate. The user changed a display name; nothing told them isolation went with it. So rename stops sending the ref at all. The update endpoint has always treated an omitted resource_ref as "keep the stored one", including in v0.4.24, so the mode survives on every server version and no capability check is needed for a rename. It also closes the general shape of this: any unrelated edit that resends a ref gets it re-normalised by whatever the server understands today. The label therefore moves to the top-level column, which is what the API already offered for exactly this, and display now reads that first and the ref's copy second — so rows created before this keep the name they were given until someone renames them. Also corrects wording review flagged as misleading: the config flag is absent on every server that predates the SIGNAL, not only on those that predate the fix. Releases between the two do gate the save but cannot say so, and are treated as unable — the client cannot tell them apart, and only one of the two guesses is safe. Co-authored-by: multica-agent <[email protected]> * fix(server): converge the two homes of a local directory's display name (MUL-6323) Review follow-up. Renaming stopped resending the ref so old servers cannot strip execution_mode — but that left the name with two writable homes: the top-level label column (what newer clients write) and the copy inside resource_ref (what desktop <= v0.4.28 both reads first and writes renames into). One row could show different names on different devices, permanently, and clearing a name resurrected the ref copy. The server is the only writer that sees both client generations, so it now converges the copies on every local_directory write: - a label-only rename patches the ref's copy too — as a raw-map edit around the other keys, so execution_mode and fields written by a NEWER server survive byte-for-byte (re-marshaling through this binary's struct is the exact silent-drop this PR exists to close); - a resent ref whose embedded label CHANGED is the legacy rename shape, and the column follows it; an unchanged embedded label (the mode dialog spreads the stored ref back) never overwrites a column rename — the write heals the row toward the column instead; - clearing removes both copies, while a column that was merely never set keeps its hands off the ref: for rows created by older clients the ref copy IS the name. The display-order helper moves to its own pure module shared by the resources section and the chat hint (review nit), with the read-order matrix pinned in a node-environment test beside it. Co-authored-by: multica-agent <[email protected]> * fix(server): tell a rename apart from a ref that went stale (MUL-6323) Review follow-up. Convergence read "the ref arrived with a different label" as "an old client renamed this", but that is also what a stale snapshot looks like. The execution-mode dialog captures the whole ref when it OPENS and sends it back on save, so: device A opens the dialog, device B renames the folder, device A saves a mode — and the server writes B's rename back to the old name, from a dialog that has nothing to do with naming. Only a ref whose SOLE difference is its label counts as a rename now. Anything else is an edit carrying a name it happens to have been holding, so the row keeps whatever it is called today — in both homes, which also heals the stale copy the request brought. Compared as decoded values, and unknown keys count: a difference this binary cannot interpret is still a difference, and reading such a request as a pure rename would be a guess. The same classification fixes a second complaint from the other direction. An old client's rename resends the ref, so the worktree capability gate refused it whenever the machine's runtime registration had drifted — failing an edit that changes nothing about what runs, which is exactly what that gate's comment already promised label-only updates would be spared. A rename skips it; the row already says worktree, and the claim gate is what keeps it from running somewhere that cannot. TestProjectResourceUpdateLifecycle pinned the old reading for a request that changes path, daemon and label at once. No client sends that shape — renames change only the label, the mode dialog changes only the mode — and honouring an embedded label there is indistinguishable from honouring a stale one, so it now pins the conservative answer. Callers that mean to rename have the top-level label field. Also softens a claim review flagged as overstated: the label patch preserves every unknown key and value, not the raw bytes, which JSONB does not promise either. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
206 lines
8.8 KiB
Go
206 lines
8.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/multica-ai/multica/server/internal/analytics"
|
|
"github.com/multica-ai/multica/server/internal/featureflags"
|
|
)
|
|
|
|
type AppConfig struct {
|
|
CdnDomain string `json:"cdn_domain"`
|
|
// CdnSigned tells clients that the CDN domain above serves PRIVATE
|
|
// content through time-bounded signed URLs (CloudFront signing is
|
|
// enabled). When true, a raw storage URL on the CDN domain is NOT
|
|
// publicly fetchable — renderers must not pick it as a native
|
|
// <img>/<video> source and should fall back to the per-attachment
|
|
// API endpoint or a freshly signed download_url instead (MUL-3254).
|
|
// Omitted when false so older clients see the previous shape.
|
|
CdnSigned bool `json:"cdn_signed,omitempty"`
|
|
// Public auth config consumed by the web app at runtime so self-hosted
|
|
// deployments do not need to rebuild the frontend image when operators
|
|
// toggle signup or wire Google OAuth.
|
|
AllowSignup bool `json:"allow_signup"`
|
|
GoogleClientID string `json:"google_client_id,omitempty"`
|
|
// WorkspaceCreationDisabled mirrors the server-side
|
|
// DISABLE_WORKSPACE_CREATION env var so the UI can hide every
|
|
// "Create workspace" affordance on self-hosted instances. Omitted
|
|
// from the JSON when false to keep responses identical to the
|
|
// previous shape for the common managed-cloud case (#3433).
|
|
WorkspaceCreationDisabled bool `json:"workspace_creation_disabled,omitempty"`
|
|
// Public daemon setup config consumed by the web app at runtime so
|
|
// self-hosted instances can show `multica setup self-host` commands
|
|
// with the operator's own domains instead of Multica Cloud defaults.
|
|
DaemonServerURL string `json:"daemon_server_url,omitempty"`
|
|
DaemonAppURL string `json:"daemon_app_url,omitempty"`
|
|
|
|
// VCSIntegrationAvailable mirrors the MULTICA_VCS_INTEGRATION_ENABLED
|
|
// deployment switch so the Settings UI can hide the whole self-hosted Git
|
|
// provider section on deployments where it is off (the managed cloud),
|
|
// instead of rendering it and surfacing an operator-only "missing
|
|
// MULTICA_VCS_SECRET_KEY" hint a cloud user cannot resolve. Omitted when
|
|
// false so the managed-cloud response keeps its previous shape; the UI
|
|
// defaults absent to false (hidden).
|
|
VCSIntegrationAvailable bool `json:"vcs_integration_available,omitempty"`
|
|
|
|
// PostHog public config for the frontend. The key is the same Project
|
|
// API Key the backend uses; returning it here (instead of baking it
|
|
// into the frontend bundle via NEXT_PUBLIC_*) means self-hosted
|
|
// instances — whose server returns an empty key — automatically
|
|
// disable frontend event shipping too.
|
|
PosthogKey string `json:"posthog_key"`
|
|
PosthogHost string `json:"posthog_host"`
|
|
AnalyticsEnvironment string `json:"analytics_environment"`
|
|
|
|
// FeatureFlags exposes only frontend-safe boolean decisions. Do not dump
|
|
// raw rules here: /api/config is public and may be called anonymously.
|
|
FeatureFlags map[string]bool `json:"feature_flags,omitempty"`
|
|
|
|
// LocalWorktreeSupported tells clients this server understands
|
|
// local_directory `execution_mode` and enforces the worktree capability
|
|
// gate when a resource is saved.
|
|
//
|
|
// Load-bearing for CLIENTS, not for this server. Releases before v0.4.25
|
|
// unmarshalled the ref into a struct without the field and re-marshalled
|
|
// it, so `execution_mode: "worktree"` was silently DROPPED and answered
|
|
// 201 — the resource then ran in_place, editing the working copy the user
|
|
// asked to isolate, with no gate anywhere to catch it. A new client cannot
|
|
// tell that from success, so it has to ask first, and absent has to read as
|
|
// "cannot honour it": every release that drops the field also omits this
|
|
// one. Releases between that fix and this signal do gate the save but say
|
|
// nothing, so they are treated the same way — the client cannot distinguish
|
|
// them, and only one of the two guesses is safe.
|
|
LocalWorktreeSupported bool `json:"local_worktree_supported"`
|
|
|
|
// ServerVersion is the running API build version, so self-hosted
|
|
// operators can confirm what's deployed and include it in bug reports.
|
|
// Only emitted on self-hosted deployments — omitted on the managed cloud,
|
|
// which is continuously deployed so its users can't act on the version —
|
|
// and empty for dev builds that aren't stamped via -X main.version.
|
|
ServerVersion string `json:"server_version,omitempty"`
|
|
}
|
|
|
|
// GetConfig is mounted on the public (unauthenticated) route group because
|
|
// the web app calls it before login to decide whether to render the Google
|
|
// sign-in button and signup UI. Only add fields here that are safe to expose
|
|
// to anonymous callers — never user- or tenant-scoped data.
|
|
func (h *Handler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
|
config := AppConfig{
|
|
// A property of this build, not of the deployment: if this code is
|
|
// running, the save gate is running with it.
|
|
LocalWorktreeSupported: true,
|
|
AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
|
|
GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
|
WorkspaceCreationDisabled: os.Getenv("DISABLE_WORKSPACE_CREATION") == "true",
|
|
}
|
|
if h.Storage != nil {
|
|
config.CdnDomain = h.Storage.CdnDomain()
|
|
}
|
|
config.CdnSigned = h.CFSigner != nil
|
|
config.DaemonServerURL, config.DaemonAppURL = daemonSetupURLsFromEnv()
|
|
config.VCSIntegrationAvailable = h.cfg.VCSIntegrationEnabled
|
|
config.FeatureFlags = featureflags.EvaluateFrontendPublicFlags(r.Context(), h.FeatureFlags)
|
|
// Only surface the build version on self-hosted deployments. The managed
|
|
// cloud is continuously deployed and its users can't choose the build, so
|
|
// the Help popover's version row would just be noise there (MUL-4108).
|
|
if !isOfficialCloudDeployment() {
|
|
config.ServerVersion = h.cfg.ServerVersion
|
|
}
|
|
|
|
// Re-read from env on every request so operators can rotate keys via
|
|
// secret refresh without a server restart.
|
|
if v := os.Getenv("ANALYTICS_DISABLED"); v != "true" && v != "1" {
|
|
config.PosthogKey = os.Getenv("POSTHOG_API_KEY")
|
|
config.PosthogHost = os.Getenv("POSTHOG_HOST")
|
|
config.AnalyticsEnvironment = analytics.EnvironmentFromEnv()
|
|
if config.PosthogHost == "" && config.PosthogKey != "" {
|
|
config.PosthogHost = "https://us.i.posthog.com"
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, config)
|
|
}
|
|
|
|
func daemonSetupURLsFromEnv() (string, string) {
|
|
serverURL := normalizePublicURL(os.Getenv("MULTICA_PUBLIC_URL"))
|
|
appURL := resolveFrontendAppURL()
|
|
if appURL == "" {
|
|
return "", ""
|
|
}
|
|
|
|
if serverURL == "" {
|
|
serverURL = appURL
|
|
}
|
|
if isOfficialCloudDaemonConfig(appURL) {
|
|
return "", ""
|
|
}
|
|
return serverURL, appURL
|
|
}
|
|
|
|
// resolveFrontendAppURL returns the operator-configured frontend origin
|
|
// (MULTICA_APP_URL, falling back to FRONTEND_ORIGIN), normalized. Shared by
|
|
// the daemon-setup URLs and the managed-cloud detection so both read the same
|
|
// signal.
|
|
func resolveFrontendAppURL() string {
|
|
appURL := normalizePublicURL(os.Getenv("MULTICA_APP_URL"))
|
|
if appURL == "" {
|
|
appURL = normalizePublicURL(os.Getenv("FRONTEND_ORIGIN"))
|
|
}
|
|
return appURL
|
|
}
|
|
|
|
func normalizePublicURL(raw string) string {
|
|
return strings.TrimRight(strings.TrimSpace(raw), "/")
|
|
}
|
|
|
|
// isOfficialCloudDaemonConfig reports whether this deployment is the official
|
|
// Multica Cloud, identified by its frontend host alone (multica.ai). The
|
|
// daemon setup for the managed cloud is always
|
|
// `multica setup` (which hardcodes api.multica.ai), so the per-deployment URLs
|
|
// must be omitted from /api/config even when MULTICA_PUBLIC_URL is unset or
|
|
// misconfigured. Previously this also required serverURL==api.multica.ai, so a
|
|
// cloud deployment that forgot MULTICA_PUBLIC_URL fell through and emitted a
|
|
// `setup self-host --server-url https://multica.ai` command — pointing the
|
|
// daemon's backend at the frontend (no /health, no WebSocket proxy).
|
|
func isOfficialCloudDaemonConfig(appURL string) bool {
|
|
return urlHostEquals(appURL, "multica.ai")
|
|
}
|
|
|
|
// isOfficialCloudDeployment reports whether this server is the official Multica
|
|
// Cloud, reusing the same frontend-host signal as the daemon setup (multica.ai).
|
|
// Managed-cloud-only behavior — such as suppressing the Help popover's
|
|
// server-version row, which only matters to self-hosted operators — is gated on
|
|
// this.
|
|
func isOfficialCloudDeployment() bool {
|
|
return isOfficialCloudDaemonConfig(resolveFrontendAppURL())
|
|
}
|
|
|
|
func urlHostEquals(raw, want string) bool {
|
|
host := canonicalURLHost(raw)
|
|
if host == "" {
|
|
return false
|
|
}
|
|
want = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(want)), ".")
|
|
return host == want
|
|
}
|
|
|
|
func canonicalURLHost(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" && !strings.Contains(raw, "://") {
|
|
u, err = url.Parse("https://" + raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
host = u.Hostname()
|
|
}
|
|
return strings.TrimSuffix(strings.ToLower(host), ".")
|
|
}
|