mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
feat(daemon): report full CLI build commit in local identity (MUL-7951) (#9164)
Co-authored-by: jikuya <[email protected]>
This commit is contained in:
co-authored by
jikuya
parent
3647ec9a9c
commit
d6956ede15
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user