feat(daemon): report full CLI build commit in local identity (MUL-7951) (#9164)

Co-authored-by: jikuya <[email protected]>
This commit is contained in:
jikuya-kunihiko
2026-10-11 23:35:55 -04:00
committed by GitHub
co-authored by jikuya
parent 3647ec9a9c
commit d6956ede15
11 changed files with 240 additions and 6 deletions
+1
View File
@@ -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
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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})`,
@@ -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 });
}
});
}
}
+3 -2
View File
@@ -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
+2
View File
@@ -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")
@@ -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()
+2
View File
@@ -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
+1
View File
@@ -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)
+4
View File
@@ -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(),
+90
View File
@@ -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()