From d6956ede15a198e59b17446d98fbbfbb609860aa Mon Sep 17 00:00:00 2001 From: jikuya-kunihiko <122000952+jikuya-kunihiko@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:35:55 +0900 Subject: [PATCH] feat(daemon): report full CLI build commit in local identity (MUL-7951) (#9164) Co-authored-by: jikuya --- .goreleaser.yml | 1 + Makefile | 5 +- apps/desktop/scripts/bundle-cli.mjs | 5 +- apps/desktop/scripts/bundle-cli.node-test.mjs | 78 ++++++++++++++++ scripts/dev-env.sh | 5 +- server/cmd/multica/cmd_daemon.go | 2 + .../cmd/multica/cmd_daemon_identity_test.go | 53 +++++++++++ server/cmd/multica/main.go | 2 + server/internal/daemon/config.go | 1 + server/internal/daemon/health.go | 4 + server/internal/daemon/health_test.go | 90 +++++++++++++++++++ 11 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/scripts/bundle-cli.node-test.mjs diff --git a/.goreleaser.yml b/.goreleaser.yml index d0b861f1c..fd1dde4a2 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -17,6 +17,7 @@ builds: - -s -w - -X main.version={{.Version}} - -X main.commit={{.ShortCommit}} + - -X main.buildCommit={{.FullCommit}} - -X main.date={{.Date}} env: - CGO_ENABLED=0 diff --git a/Makefile b/Makefile index 892eb579e..2f849b544 100644 --- a/Makefile +++ b/Makefile @@ -319,10 +319,11 @@ cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source @$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)" multica: ## Run the multica CLI entrypoint directly from the Go source tree - cd server && go run -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" ./cmd/multica $(MULTICA_ARGS) + cd server && go run -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildCommit=$(BUILD_COMMIT) -X main.date=$(DATE)" ./cmd/multica $(MULTICA_ARGS) VERSION ?= $(shell git describe --tags --match 'v[0-9]*' --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +BUILD_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') # Windows will not execute an extensionless binary, so a source build there has # to name its outputs the way the target platform expects — otherwise the CLI @@ -338,7 +339,7 @@ DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') build: EXE = $(if $(filter windows,$(or $(GOOS),$(shell go env GOOS))),.exe,) build: ## Build the server, CLI, and migrate binaries into server/bin cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server$(EXE) ./cmd/server - cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica$(EXE) ./cmd/multica + cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildCommit=$(BUILD_COMMIT) -X main.date=$(DATE)" -o bin/multica$(EXE) ./cmd/multica cd server && go build -o bin/migrate$(EXE) ./cmd/migrate test: ## Run Go tests after ensuring the target DB exists and migrations are applied diff --git a/apps/desktop/scripts/bundle-cli.mjs b/apps/desktop/scripts/bundle-cli.mjs index d8e5c56e3..3573cf2c8 100644 --- a/apps/desktop/scripts/bundle-cli.mjs +++ b/apps/desktop/scripts/bundle-cli.mjs @@ -78,7 +78,7 @@ const destBinary = join(destDir, binName); // Hand git arguments straight to the binary (no shell) on every platform. function git(...args) { try { - return execFileSync("git", args, { encoding: "utf-8" }).trim(); + return execFileSync("git", args, { encoding: "utf-8", cwd: repoRoot }).trim(); } catch { return ""; } @@ -105,8 +105,9 @@ async function exists(p) { if (hasGo()) { const version = deriveVersion() || "dev"; const commit = git("rev-parse", "--short", "HEAD") || "unknown"; + const buildCommit = git("rev-parse", "HEAD") || "unknown"; const date = new Date().toISOString().replace(/\.\d+Z$/, "Z"); - const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`; + const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.buildCommit=${buildCommit} -X main.date=${date}`; console.log( `[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`, diff --git a/apps/desktop/scripts/bundle-cli.node-test.mjs b/apps/desktop/scripts/bundle-cli.node-test.mjs new file mode 100644 index 000000000..cd2853d37 --- /dev/null +++ b/apps/desktop/scripts/bundle-cli.node-test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const fullRevision = "a".repeat(40); +const scriptDir = fileURLToPath(new URL(".", import.meta.url)); + +function fakeExecutable(path, source) { + writeFileSync(path, `#!${process.execPath}\n${source}`, { mode: 0o755 }); +} + +for (const [platform, arch, goos, goarch, foreignCaller] of [ + ["darwin", "arm64", "darwin", "arm64", false], + ["linux", "x64", "linux", "amd64", false], + ["win32", "arm64", "windows", "arm64", false], + ["darwin", "arm64", "darwin", "arm64", true], + ["linux", "x64", "linux", "amd64", true], + ["win32", "arm64", "windows", "arm64", true], +]) { + for (const missingRevision of [false, true]) { + test(`bundled ${platform}/${arch} preserves source-root commit (missing=${missingRevision}, foreignCaller=${foreignCaller})`, () => { + const root = mkdtempSync(join(tmpdir(), "multica-bundle-commit-")); + try { + const scripts = join(root, "apps", "desktop", "scripts"); + const bin = join(root, "fake-bin"); + const record = join(root, "build.json"); + const caller = join(root, "foreign-caller"); + mkdirSync(scripts, { recursive: true }); + mkdirSync(bin); + mkdirSync(join(root, "server")); + mkdirSync(caller); + for (const name of ["bundle-cli.mjs", "package.mjs"]) { + copyFileSync(join(scriptDir, name), join(scripts, name)); + } + fakeExecutable(join(bin, "git"), ` +const args = process.argv.slice(2); +if (args[0] === "describe") console.log("v9.9.9"); +else if (args.join(" ") === "rev-parse --short HEAD") console.log("abc1234"); +else if (args.join(" ") === "rev-parse HEAD") { + if (process.env.TEST_MISSING_REVISION === "true") process.exit(1); + console.log(process.cwd() === process.env.TEST_SOURCE_ROOT ? ${JSON.stringify(fullRevision)} : "b".repeat(40)); +} else process.exit(2); +`); + fakeExecutable(join(bin, "go"), ` +const args = process.argv.slice(2); +if (args[0] === "version") console.log("go version go1.27.1 fake/fake"); +else if (args[0] === "build") { + const fs = await import("node:fs"); + fs.writeFileSync(process.env.TEST_BUILD_RECORD, JSON.stringify({args, goos:process.env.GOOS, goarch:process.env.GOARCH, cgo:process.env.CGO_ENABLED})); + fs.writeFileSync(args[args.indexOf("-o") + 1], "fake artifact"); +} else process.exit(2); +`); + fakeExecutable(join(bin, "codesign"), "process.exit(0);\n"); + execFileSync(process.execPath, [join(scripts, "bundle-cli.mjs"), "--target-platform", platform, "--target-arch", arch], { + cwd: foreignCaller ? caller : root, + env: { PATH: bin, HOME: root, TEST_SOURCE_ROOT: realpathSync(root), TEST_BUILD_RECORD: record, TEST_MISSING_REVISION: String(missingRevision) }, + stdio: "pipe", + }); + const build = JSON.parse(readFileSync(record, "utf8")); + const flags = build.args[build.args.indexOf("-ldflags") + 1].split(" "); + assert.ok(flags.includes("main.version=9.9.9")); + assert.ok(flags.includes("main.commit=abc1234")); + assert.ok(flags.includes(`main.buildCommit=${missingRevision ? "unknown" : fullRevision}`)); + assert.equal(build.goos, goos); + assert.equal(build.goarch, goarch); + assert.equal(build.cgo, "0"); + const artifact = platform === "win32" ? "multica.exe" : "multica"; + assert.equal(readFileSync(join(root, "apps", "desktop", "resources", "bin", artifact), "utf8"), "fake artifact"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + } +} diff --git a/scripts/dev-env.sh b/scripts/dev-env.sh index cdd8a1f41..fb7dd1727 100755 --- a/scripts/dev-env.sh +++ b/scripts/dev-env.sh @@ -723,7 +723,7 @@ daemon_task_marker() { } start_daemon() { - local status state + local status state build_commit ensure_credentials # Built, never `go run`: the daemon records its own executable path at startup @@ -732,7 +732,8 @@ start_daemon() { # daemon registers, heartbeats, and then fails every task with # "fork/exec .../go-build.../exe/multica: no such file or directory". info "Building $MULTICA_BIN (a go run daemon would fail every task later)." - (cd "$REPO_ROOT/server" && go build -o bin/multica ./cmd/multica) || die "Failed to build the multica CLI." + build_commit="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || printf 'unknown')" + (cd "$REPO_ROOT/server" && go build -ldflags "-X main.buildCommit=$build_commit" -o bin/multica ./cmd/multica) || die "Failed to build the multica CLI." "${CLEAN_ENV[@]}" MULTICA_WORKSPACES_ROOT="$WORKSPACES_ROOT" \ "$MULTICA_BIN" daemon start --profile "$PROFILE" 2>&1 | sed 's/^/ /' || true diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index a889ac417..59005c086 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -1071,6 +1071,8 @@ func runDaemonForeground(cmd *cobra.Command) error { return err } cfg.CLIVersion = version + // Report this process's embedded revision, never the binary currently on disk. + cfg.CLICommit = buildCommit // Set by the Electron Desktop app when it spawns the CLI so the server // can mark those runtimes as "managed" and hide CLI self-update UI. cfg.LaunchedBy = os.Getenv("MULTICA_LAUNCHED_BY") diff --git a/server/cmd/multica/cmd_daemon_identity_test.go b/server/cmd/multica/cmd_daemon_identity_test.go index a7296e85c..ce1fb578a 100644 --- a/server/cmd/multica/cmd_daemon_identity_test.go +++ b/server/cmd/multica/cmd_daemon_identity_test.go @@ -325,6 +325,59 @@ func TestDaemonStatusShowsWhoManagesTheDaemon(t *testing.T) { // so verifying identity would compare that empty profile against a named- // profile host and report a phantom conflict — breaking the standing contract // that `daemon status` in a task reports on the daemon hosting it. +func TestDaemonStatusJSONPreservesServingCLICommitWithoutFallback(t *testing.T) { + // The command's embedded revision must not replace the serving process's value. + servingCommit := strings.Repeat("a", 40) + if servingCommit == buildCommit { + servingCommit = strings.Repeat("b", 40) + } + for _, tc := range []struct { + name string + present bool + value string + }{ + {"serving revision", true, servingCommit}, + {"unknown", true, "unknown"}, + {"empty", true, ""}, + {"older daemon", false, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + clearDaemonTaskEnv(t) + mkProfiles(t, "commit-test") + health := map[string]any{ + "profile": "commit-test", "daemon_id": "daemon-test", + "server_url": "https://api.example.test", "cli_version": "v9.9.9", + } + if tc.present { + health["cli_commit"] = tc.value + } + serveHealthAs(t, "commit-test", health) + out, err := captureStdout(t, func() error { + return runDaemonStatus(daemonStatusCmdFor(t, "commit-test", "json"), nil) + }) + if err != nil { + t.Fatalf("runDaemonStatus: %v", err) + } + var payload map[string]any + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode status JSON: %v", err) + } + got, present := payload["cli_commit"] + if present != tc.present || tc.present && got != tc.value { + t.Fatalf("cli_commit: got %v, present %v; want %q, present %v", got, present, tc.value, tc.present) + } + for key, want := range health { + if payload[key] != want { + t.Errorf("%s: got %v, want %v", key, payload[key], want) + } + } + if payload["status"] != "running" { + t.Fatalf("status: got %v, want running", payload["status"]) + } + }) + } +} + func TestDaemonStatusInTaskContextReportsNamedProfileHost(t *testing.T) { setup := func(t *testing.T) { t.Helper() diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 81d14b6c0..2393d02eb 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -16,6 +16,8 @@ var ( version = "dev" commit = "unknown" date = "unknown" + // Separate full revision metadata from the existing display commit. + buildCommit = "unknown" ) // debugFlag is bound to the persistent --debug flag and, when set, makes diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 638df6a43..54fc09654 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -108,6 +108,7 @@ type Config struct { DeviceName string RuntimeName string CLIVersion string // multica CLI version (e.g. "0.1.13") + CLICommit string // full build revision embedded in the running CLI; empty/unknown if unavailable LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone Profile string // profile name (empty = default) Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, codearts, deveco, openclaw, hermes, pi, cursor, kimi, reasonix, dsh, kiro, antigravity, qoder, qoderclicn, traecli, grok, qwen, qwenpaw, mcode, dim, zeroclaw (plus built-in runtime identities from agent.BuiltinRuntimes, e.g. omp) diff --git a/server/internal/daemon/health.go b/server/internal/daemon/health.go index 8e3ebed67..7aad07c30 100644 --- a/server/internal/daemon/health.go +++ b/server/internal/daemon/health.go @@ -45,6 +45,9 @@ type HealthResponse struct { DeviceName string `json:"device_name"` ServerURL string `json:"server_url"` CLIVersion string `json:"cli_version"` + // CLICommit belongs to this running process, not an installed replacement. + // Empty or unknown values do not attest a revision; never fall back to version. + CLICommit string `json:"cli_commit"` // LaunchedBy is "desktop" when the Electron app spawned this daemon, empty // for a standalone one. Already reported to the server on registration; // surfaced here so `daemon status` can say who manages the daemon instead @@ -353,6 +356,7 @@ func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc { DeviceName: d.cfg.DeviceName, ServerURL: d.cfg.ServerBaseURL, CLIVersion: d.cfg.CLIVersion, + CLICommit: d.cfg.CLICommit, ActiveTaskCount: d.activeTasks.Load(), RunningTaskCount: d.runningTasks.Load(), ResourceWaitTaskCount: d.resourceWaitTasks.Load(), diff --git a/server/internal/daemon/health_test.go b/server/internal/daemon/health_test.go index 88268717e..502612d7a 100644 --- a/server/internal/daemon/health_test.go +++ b/server/internal/daemon/health_test.go @@ -20,6 +20,96 @@ import ( "github.com/multica-ai/multica/server/internal/daemon/repocache" ) +func TestHealthHandlerReportsEmptyCLICommitWithoutFallback(t *testing.T) { + d := &Daemon{ + cfg: Config{CLIVersion: "v9.9.9", DaemonID: "daemon-test", ServerBaseURL: "https://api.example.test"}, + workspaces: map[string]*workspaceState{}, + logger: slog.Default(), + } + rec := httptest.NewRecorder() + d.healthHandler(time.Now()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + var raw map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode health response: %v", err) + } + if got, present := raw["cli_commit"]; !present || got != "" { + t.Fatalf("cli_commit: got %v, present %v; want an explicit empty string without version fallback", got, present) + } +} + +func TestHealthHandlerReportsCLICommitAndSameIdentitySnapshot(t *testing.T) { + for _, commit := range []string{strings.Repeat("a", 40), "", "unknown", "2ae2dbb", "malformed"} { + t.Run("commit="+commit, func(t *testing.T) { + d := New(Config{ + WorkspacesRoot: t.TempDir(), CLICommit: commit, CLIVersion: "v9.9.9", + Profile: "profile-test", DaemonID: "daemon-test", DeviceName: "device-test", + ServerBaseURL: "https://api.example.test", + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + d.workspaces["workspace-test"] = &workspaceState{runtimeIDs: []string{"runtime-test"}} + d.ready.Store(true) + d.activeTasks.Store(2) + d.runningTasks.Store(1) + d.resourceWaitTasks.Store(1) + rec := httptest.NewRecorder() + d.healthHandler(time.Now()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("health status: got %d, want 200", rec.Code) + } + var raw map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode raw health response: %v", err) + } + for key, want := range map[string]any{ + "cli_commit": commit, "cli_version": "v9.9.9", "profile": "profile-test", + "daemon_id": "daemon-test", "server_url": "https://api.example.test", + "device_name": "device-test", "status": "running", "os": runtime.GOOS, + "active_task_count": float64(2), "running_task_count": float64(1), + "resource_wait_task_count": float64(1), + } { + if got, present := raw[key]; !present || got != want { + t.Errorf("%s: got %v, present %v; want %v", key, got, present, want) + } + } + var typed HealthResponse + if err := json.Unmarshal(rec.Body.Bytes(), &typed); err != nil { + t.Fatalf("decode typed health response: %v", err) + } + if typed.CLICommit != commit || len(typed.Workspaces) != 1 || + typed.Workspaces[0].ID != "workspace-test" || len(typed.Workspaces[0].Runtimes) != 1 || + typed.Workspaces[0].Runtimes[0] != "runtime-test" { + t.Fatalf("commit or workspace/runtime projection changed: %+v", typed) + } + }) + } +} + +func TestHealthHandlerCLICommitIsCopiedAtDaemonConstruction(t *testing.T) { + cfg := Config{WorkspacesRoot: t.TempDir(), CLICommit: strings.Repeat("a", 40), + CLIVersion: "v9.9.9", Profile: "profile-test", DaemonID: "daemon-test", + ServerBaseURL: "https://api.example.test"} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + d := New(cfg, logger) + read := func(d *Daemon) HealthResponse { + t.Helper() + rec := httptest.NewRecorder() + d.healthHandler(time.Now()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + var resp HealthResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode health response: %v", err) + } + return resp + } + // Changing the input for a later instance cannot change the running instance's build identity. + cfg.CLICommit = strings.Repeat("b", 40) + cfg.CLIVersion = "v10.0.0" + if got := read(d); got.CLICommit != strings.Repeat("a", 40) || got.CLIVersion != "v9.9.9" { + t.Fatalf("original daemon identity changed: %+v", got) + } + if got := read(New(cfg, logger)); got.CLICommit != strings.Repeat("b", 40) || got.CLIVersion != "v10.0.0" { + t.Fatalf("new daemon did not use new build identity: %+v", got) + } +} + func TestHealthHandlerReportsCLIVersionAndTaskCounts(t *testing.T) { t.Parallel()