Compare commits

...

3 Commits

Author SHA1 Message Date
Jiayuan Zhang
d37d7ae7ac refactor(quick-create): exempt git-describe daemons instead of env bypass
Replaces the per-environment bypass added in the previous commit with a
shared daemon-version signal. CheckMinCLIVersion / checkQuickCreateCliVersion
now treat any daemon whose CLI version matches the
`vX.Y.Z-N-gHASH[-dirty]` git-describe shape as OK; tagged releases keep
going through the normal min-version comparison.

Why: Emacs flagged that (a) NODE_ENV !== "production" also disables the
gate on staging and other non-prod deployments, undoing the protection
for the case the gate was originally written for, and (b) NODE_ENV (web
client) and APP_ENV (server) are not equivalent, so the modal pre-check
and server gate could disagree on the same request. Both go away when
the signal is intrinsic to the daemon's version string.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 08:24:38 +08:00
Jiayuan Zhang
c45cc052e8 refactor(quick-create): skip daemon CLI version gate in dev
Restores the gate (reverts the full-removal commit) and bypasses it in
non-production environments instead. The motivation for the original
removal — local source-built daemons report a `git describe` version
like v0.2.15-N-gHASH that parses below 0.2.20 and blocks dev testing —
is now handled by checking APP_ENV on the server and NODE_ENV on the
client. Production keeps the original "needs upgrade" UX.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 08:15:28 +08:00
Jiayuan Zhang
9dd90eef22 refactor(quick-create): remove daemon CLI version gate
Local-source daemons report dev-suffixed versions (e.g.
v0.2.15-235-gdaf0e935) that the picker pre-check and server gate both
treat as too old, blocking quick-create during local testing.

Drops the gate end-to-end: removes MinQuickCreateCLIVersion +
CheckMinCLIVersion in pkg/agent, the checkQuickCreateDaemonVersion
handler and readRuntimeCLIVersion helper in handler/issue.go, and the
mirrored cli-version.ts plus the modal's pre-check, blocked-state UI,
and daemon_version_unsupported error branch.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 08:04:53 +08:00
6 changed files with 88 additions and 4 deletions

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { checkQuickCreateCliVersion } from "./cli-version";
describe("checkQuickCreateCliVersion", () => {
it("returns ok for a tagged release at or above the minimum", () => {
expect(checkQuickCreateCliVersion("v0.2.20").state).toBe("ok");
expect(checkQuickCreateCliVersion("0.3.1").state).toBe("ok");
});
it("returns too_old for a tagged release below the minimum", () => {
expect(checkQuickCreateCliVersion("v0.2.15").state).toBe("too_old");
});
it("returns missing for empty or unparsable input", () => {
expect(checkQuickCreateCliVersion("").state).toBe("missing");
expect(checkQuickCreateCliVersion(undefined).state).toBe("missing");
expect(checkQuickCreateCliVersion("not-a-version").state).toBe("missing");
});
it("treats git-describe dev builds as ok regardless of base tag", () => {
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935").state).toBe("ok");
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935-dirty").state).toBe("ok");
expect(checkQuickCreateCliVersion("0.1.0-1-gabc1234").state).toBe("ok");
});
});

View File

@@ -24,6 +24,14 @@ export interface CliVersionCheck {
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
// Matches the `git describe --tags --always --dirty` output for a build past
// the latest tag, e.g. `v0.2.15-235-gdaf0e935` or `v0.2.15-235-gdaf0e935-dirty`.
// Daemons built from source (Makefile `make build` / `make daemon`) report this
// shape; tagged releases are bare semver. Treating dev-described daemons as OK
// is what keeps `pnpm dev:desktop` + `make daemon` unblocked without weakening
// the gate for staging or production users running stale stable releases.
const DEV_DESCRIBE_RE = /^v?\d+\.\d+\.\d+-\d+-g[0-9a-fA-F]+/;
function parseSemver(raw: string): [number, number, number] | null {
const m = SEMVER_RE.exec(raw.trim());
if (!m) return null;
@@ -40,9 +48,14 @@ function lessThan(a: [number, number, number], b: [number, number, number]) {
* Check a daemon-reported CLI version string against the minimum. Returns
* `"missing"` for empty/unparsable input (fail closed — same policy as the
* server) and `"too_old"` for a parsable version below the threshold.
* Dev-built daemons (git-describe shape) are always OK — the version string
* itself is the shared signal, so frontend and server agree by construction.
*/
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
const current = (detected ?? "").trim();
if (DEV_DESCRIBE_RE.test(current)) {
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
}
const parsed = current ? parseSemver(current) : null;
if (!parsed) {
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };

View File

@@ -116,7 +116,10 @@ export function AgentCreatePanel({
// daemons handle attachments and partial-failure retries incorrectly
// (see PR #1851 / MUL-1496). Pre-check on the picker so the user gets
// immediate feedback instead of waiting for the inbox failure; the
// server re-validates as the trust boundary.
// server re-validates as the trust boundary. Dev-built daemons
// (git-describe shape) are exempted inside checkQuickCreateCliVersion
// — frontend and server share the same signal there, so they agree by
// construction across web/desktop/staging without comparing env flags.
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const selectedRuntime = useMemo(
() =>

View File

@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -13,8 +14,6 @@ import (
"time"
"unicode"
"errors"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
@@ -937,7 +936,9 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
// handling, no-retry on partial failure). Older daemons either
// double-create issues on partial CLI failures or mishandle pasted
// screenshot URLs; fail closed before enqueuing rather than surface
// the breakage as an inbox failure twenty seconds later.
// the breakage as an inbox failure twenty seconds later. Dev-built
// daemons (git-describe shape) are exempted inside CheckMinCLIVersion
// so `make daemon` works without weakening staging or production.
if status, payload := h.checkQuickCreateDaemonVersion(r.Context(), agent.RuntimeID); status != 0 {
writeJSON(w, status, payload)
return

View File

@@ -32,15 +32,30 @@ var (
ErrCLIVersionTooOld = errors.New("multica CLI version is below required minimum")
)
// devDescribeRe matches the `git describe --tags --always --dirty` output for
// a build past the latest tag, e.g. `v0.2.15-235-gdaf0e935` (optionally with a
// trailing `-dirty`). Daemons built from source (Makefile `make build` / `make
// daemon`) report this shape; tagged releases are bare semver. Treating dev-
// described daemons as OK keeps `make daemon` unblocked without weakening the
// gate for staging or production users running stale stable releases.
var devDescribeRe = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+-g[0-9a-fA-F]+`)
// CheckMinCLIVersion returns nil when `detected` parses as ≥ minimum. Returns
// ErrCLIVersionMissing for empty or unparsable input, and ErrCLIVersionTooOld
// when parsable but below the minimum. The caller can check for these
// sentinel errors with errors.Is to drive the response shape.
//
// Dev-built daemons (git-describe shape) always pass — the version string
// itself is the shared signal, so the modal pre-check and this server gate
// agree by construction without needing to compare separate env flags.
func CheckMinCLIVersion(detected string) error {
d := strings.TrimSpace(detected)
if d == "" {
return ErrCLIVersionMissing
}
if devDescribeRe.MatchString(d) {
return nil
}
parsed, err := parseSemver(d)
if err != nil {
return ErrCLIVersionMissing

View File

@@ -1,6 +1,7 @@
package agent
import (
"errors"
"testing"
)
@@ -51,6 +52,32 @@ func TestSemverLessThan(t *testing.T) {
}
}
func TestCheckMinCLIVersion(t *testing.T) {
tests := []struct {
name string
input string
wantErr error
}{
{"tagged release at minimum", "v0.2.20", nil},
{"tagged release above minimum", "0.3.1", nil},
{"tagged release below minimum", "v0.2.15", ErrCLIVersionTooOld},
{"empty string", "", ErrCLIVersionMissing},
{"unparsable", "not-a-version", ErrCLIVersionMissing},
{"git-describe dev build past old tag", "v0.2.15-235-gdaf0e935", nil},
{"git-describe dirty dev build", "v0.2.15-235-gdaf0e935-dirty", nil},
{"git-describe dev build past current tag", "v0.2.20-3-gabc1234", nil},
}
for _, tt := range tests {
err := CheckMinCLIVersion(tt.input)
if tt.wantErr == nil && err != nil {
t.Errorf("%s: CheckMinCLIVersion(%q) = %v, want nil", tt.name, tt.input, err)
}
if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
t.Errorf("%s: CheckMinCLIVersion(%q) = %v, want %v", tt.name, tt.input, err, tt.wantErr)
}
}
}
func TestCheckMinVersion(t *testing.T) {
tests := []struct {
agentType string