From cfa38df97bdfd1e7bf1164db8ad38cfd66229c38 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:44:19 +0800 Subject: [PATCH] feat(quick-create): gate on daemon CLI version with pre-check + server enforcement (#1857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quick-create): bound dialog height + scroll editor when content overflows Pasting a screenshot into the agent-create prompt expanded the editor unbounded, which dragged DialogContent past the viewport since the agent mode className had no max-height. Manual mode was unaffected because manualDialogContentClass pins `!h-96`. - Cap agent-mode DialogContent at `!max-h-[80vh]` (width stays `!max-w-xl`); short prompts still render compact, tall content stops at 80% of the viewport. - Switch the editor wrapper to `flex-1 min-h-[140px] overflow-y-auto` so it absorbs the remaining vertical space inside the now-bounded DialogContent and scrolls internally instead of pushing the dialog. * feat(quick-create): gate on daemon CLI version with pre-check + server enforcement The agent-create flow depends on multica CLI behavior introduced in v0.2.20 (URL attachment handling, no-retry semantics on `multica issue create` failure — see PR #1851 / MUL-1496). Older daemons either double-create issues on partial CLI failures or mishandle pasted screenshot URLs. Per J's review on MUL-1496, gate the flow at two layers — frontend pre-check for fast feedback, server re-check as the trust boundary, both fail-closed on missing/unparsable versions. Server: - New MinQuickCreateCLIVersion + CheckMinCLIVersion helper in pkg/agent (with sentinel errors for missing vs too-old). - QuickCreateIssue handler reads runtime metadata.cli_version and returns a stable 422 { code: "daemon_version_unsupported", current_version, min_version, runtime_id } before enqueuing. - The check runs after the existing online + ownership validation, so all rejections surface uniformly through the modal's existing error path. Frontend: - New @multica/core/runtimes/cli-version with the min version constant, parser, and runtime-metadata reader (tiny semver, no new lib dep). - AgentCreatePanel resolves the selected agent's runtime, runs the same check, shows an inline amber notice below the agent picker when missing/too old, and disables the Create button. - Submit handler also catches the server's 422 (defensive race — runtime can re-register between pre-check and submit) and surfaces the same wording in the error row. Switching to manual create remains a clean escape hatch — manual mode doesn't talk to a daemon at all, so an outdated CLI doesn't block the user from filing the issue. --- packages/core/runtimes/cli-version.ts | 61 +++++++++++++++ packages/core/runtimes/index.ts | 1 + packages/views/modals/quick-create-issue.tsx | 69 +++++++++++++++-- server/internal/handler/issue.go | 79 ++++++++++++++++++++ server/pkg/agent/version.go | 42 +++++++++++ 5 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 packages/core/runtimes/cli-version.ts diff --git a/packages/core/runtimes/cli-version.ts b/packages/core/runtimes/cli-version.ts new file mode 100644 index 000000000..c36ad6798 --- /dev/null +++ b/packages/core/runtimes/cli-version.ts @@ -0,0 +1,61 @@ +/** + * Frontend mirror of the server's MinQuickCreateCLIVersion gate. The + * agent-create flow (Quick Create modal) requires the daemon's bundled + * multica CLI to be at least this version — older daemons either + * double-create issues on partial CLI failures or mishandle pasted + * screenshot URLs (see PR #1851 / MUL-1496). + * + * Both the frontend pre-validation in the modal and the server's + * `/api/issues/quick-create` handler enforce this; the server is the + * authoritative trust boundary, the frontend just lets us tell the user + * "your daemon needs an upgrade" before they hit submit. + */ +export const MIN_QUICK_CREATE_CLI_VERSION = "0.2.20"; + +export type CliVersionState = "ok" | "too_old" | "missing"; + +export interface CliVersionCheck { + state: CliVersionState; + /** What the daemon reported, or empty if missing/unparsable. */ + current: string; + /** The hard minimum we gate on. */ + min: string; +} + +const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/; + +function parseSemver(raw: string): [number, number, number] | null { + const m = SEMVER_RE.exec(raw.trim()); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function lessThan(a: [number, number, number], b: [number, number, number]) { + if (a[0] !== b[0]) return a[0] < b[0]; + if (a[1] !== b[1]) return a[1] < b[1]; + return a[2] < b[2]; +} + +/** + * 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. + */ +export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck { + const current = (detected ?? "").trim(); + const parsed = current ? parseSemver(current) : null; + if (!parsed) { + return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION }; + } + const min = parseSemver(MIN_QUICK_CREATE_CLI_VERSION)!; + if (lessThan(parsed, min)) { + return { state: "too_old", current, min: MIN_QUICK_CREATE_CLI_VERSION }; + } + return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION }; +} + +/** Pull `cli_version` off a runtime row's loosely-typed metadata bag. */ +export function readRuntimeCliVersion(metadata: Record | undefined): string { + const v = metadata?.cli_version; + return typeof v === "string" ? v : ""; +} diff --git a/packages/core/runtimes/index.ts b/packages/core/runtimes/index.ts index 59c6af048..e16b10bef 100644 --- a/packages/core/runtimes/index.ts +++ b/packages/core/runtimes/index.ts @@ -6,3 +6,4 @@ export * from "./local-skills"; export * from "./types"; export * from "./derive-health"; export * from "./use-runtime-health"; +export * from "./cli-version"; diff --git a/packages/views/modals/quick-create-issue.tsx b/packages/views/modals/quick-create-issue.tsx index c8c667cc2..3d01267b6 100644 --- a/packages/views/modals/quick-create-issue.tsx +++ b/packages/views/modals/quick-create-issue.tsx @@ -19,6 +19,12 @@ import { agentListOptions } from "@multica/core/workspace/queries"; import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store"; import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store"; import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store"; +import { + runtimeListOptions, + checkQuickCreateCliVersion, + readRuntimeCliVersion, + MIN_QUICK_CREATE_CLI_VERSION, +} from "@multica/core/runtimes"; import { useFileUpload } from "@multica/core/hooks/use-file-upload"; import type { Agent } from "@multica/core/types"; import { ActorAvatar } from "../common/actor-avatar"; @@ -97,6 +103,26 @@ export function AgentCreatePanel({ [visibleAgents, agentId], ); + // Daemon CLI version gate. The agent-create flow needs the runtime's + // bundled multica CLI to be ≥ MIN_QUICK_CREATE_CLI_VERSION; older + // 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. + const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId)); + const selectedRuntime = useMemo( + () => + selectedAgent?.runtime_id + ? runtimes.find((r) => r.id === selectedAgent.runtime_id) + : undefined, + [runtimes, selectedAgent?.runtime_id], + ); + const versionCheck = useMemo( + () => checkQuickCreateCliVersion(readRuntimeCliVersion(selectedRuntime?.metadata)), + [selectedRuntime?.metadata], + ); + const versionBlocked = versionCheck.state !== "ok"; + const initialPrompt = (data?.prompt as string) || ""; // The editor is uncontrolled — we read the latest markdown via the ref at // submit/switch time. `hasContent` mirrors emptiness so the Create button @@ -128,7 +154,7 @@ export function AgentCreatePanel({ const submit = async () => { const md = editorRef.current?.getMarkdown()?.trim() ?? ""; - if (!md || !agentId || submitting) return; + if (!md || !agentId || submitting || versionBlocked) return; setSubmitting(true); setError(null); try { @@ -140,16 +166,34 @@ export function AgentCreatePanel({ }); onClose(); } catch (e) { - // Server returns 422 with { code: "agent_unavailable", reason } when the - // picked agent's runtime is offline. Surface the reason in-modal so the - // user can switch to a live agent without leaving the flow. + // Server returns 422 with { code, ... } for the structured rejection + // paths the modal cares about. Surface the reason in-modal so the + // user can switch to a live agent / upgrade their daemon without + // leaving the flow. if (e instanceof ApiError && e.body && typeof e.body === "object") { - const body = e.body as { code?: string; reason?: string }; + const body = e.body as { + code?: string; + reason?: string; + current_version?: string; + min_version?: string; + }; if (body.code === "agent_unavailable") { setError(body.reason || "Agent is unavailable. Pick another agent."); setSubmitting(false); return; } + if (body.code === "daemon_version_unsupported") { + // Race fallback: the picker pre-check should normally catch this, + // but a runtime can silently re-register with an older CLI between + // pre-check and submit. Same wording as the inline notice for + // consistency. + const cur = body.current_version || "unknown"; + setError( + `This agent's daemon CLI (${cur}) is below the required ${body.min_version || MIN_QUICK_CREATE_CLI_VERSION}. Upgrade the daemon to use Create with agent.`, + ); + setSubmitting(false); + return; + } } setError("Failed to submit. Try again."); } finally { @@ -254,6 +298,14 @@ export function AgentCreatePanel({ + {selectedAgent && versionBlocked && ( +
+ {versionCheck.state === "missing" + ? `This agent's daemon doesn't report a CLI version. Create with agent needs multica CLI ≥ ${versionCheck.min}. Upgrade the daemon and reconnect, or switch to manual create.` + : `This agent's daemon CLI is ${versionCheck.current} — Create with agent needs ≥ ${versionCheck.min}. Upgrade the daemon, or switch to manual create.`} +
+ )} + {/* Prompt — same rich editor Advanced uses, so paste/drop images, mentions, and formatting all work. The dropZone wrapper enables drag-and-drop file uploads alongside paste. */} @@ -297,7 +349,12 @@ export function AgentCreatePanel({ diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 83067ea1f..8c5089349 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -13,10 +13,13 @@ import ( "time" "unicode" + "errors" + "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/logger" "github.com/multica-ai/multica/server/internal/util" + "github.com/multica-ai/multica/server/pkg/agent" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -929,6 +932,17 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) { return } + // Daemon CLI version gate. The agent-side prompt + create-flow rely on + // behaviors introduced in MinQuickCreateCLIVersion (URL attachment + // 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. + if status, payload := h.checkQuickCreateDaemonVersion(r.Context(), agent.RuntimeID); status != 0 { + writeJSON(w, status, payload) + return + } + task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, prompt) if err != nil { slog.Warn("quick-create enqueue failed", append(logger.RequestAttrs(r), "error", err)...) @@ -962,6 +976,71 @@ func (h *Handler) isRuntimeOnline(ctx context.Context, runtimeID pgtype.UUID) bo return rt.Status == "online" } +// checkQuickCreateDaemonVersion enforces MinQuickCreateCLIVersion against the +// CLI version the daemon reported at registration time (stored on the runtime +// row's metadata.cli_version). Returns (0, nil) when the version is +// acceptable, otherwise (status, payload) ready to hand to writeJSON. +// +// Failure shape is stable so the modal can branch on the `code` field and +// surface a "needs upgrade" hint that points at the specific runtime: +// +// 422 { +// "code": "daemon_version_unsupported", +// "current_version": "0.2.18" | "", +// "min_version": "0.2.20", +// "runtime_id": "" +// } +func (h *Handler) checkQuickCreateDaemonVersion(ctx context.Context, runtimeID pgtype.UUID) (int, map[string]any) { + rt, err := h.Queries.GetAgentRuntime(ctx, runtimeID) + if err != nil { + // Runtime row vanished between the online check and here — treat + // as unavailable rather than wedging the request on a 500. + return http.StatusUnprocessableEntity, map[string]any{ + "code": "agent_unavailable", + "reason": "agent's runtime is no longer registered", + } + } + current := readRuntimeCLIVersion(rt.Metadata) + switch err := agent.CheckMinCLIVersion(current); { + case err == nil: + return 0, nil + case errors.Is(err, agent.ErrCLIVersionMissing), errors.Is(err, agent.ErrCLIVersionTooOld): + return http.StatusUnprocessableEntity, map[string]any{ + "code": "daemon_version_unsupported", + "current_version": current, + "min_version": agent.MinQuickCreateCLIVersion, + "runtime_id": uuidToString(runtimeID), + } + default: + // Defensive fall-through: unknown error from the version check is + // also fail-closed, since the gate exists precisely because we + // can't trust older daemons with this flow. + return http.StatusUnprocessableEntity, map[string]any{ + "code": "daemon_version_unsupported", + "current_version": current, + "min_version": agent.MinQuickCreateCLIVersion, + "runtime_id": uuidToString(runtimeID), + } + } +} + +// readRuntimeCLIVersion pulls metadata.cli_version off a runtime row. The +// metadata column is JSONB on the wire; the daemon stores the multica CLI +// version under that key during registration (see DaemonRegister). +func readRuntimeCLIVersion(metadata []byte) string { + if len(metadata) == 0 { + return "" + } + var m map[string]any + if err := json.Unmarshal(metadata, &m); err != nil { + return "" + } + if v, ok := m["cli_version"].(string); ok { + return v + } + return "" +} + type CreateIssueRequest struct { Title string `json:"title"` Description *string `json:"description"` diff --git a/server/pkg/agent/version.go b/server/pkg/agent/version.go index b2ebfc22a..3cd2a5cb3 100644 --- a/server/pkg/agent/version.go +++ b/server/pkg/agent/version.go @@ -1,9 +1,11 @@ package agent import ( + "errors" "fmt" "regexp" "strconv" + "strings" ) // MinVersions defines the minimum required CLI version for each agent type. @@ -14,6 +16,46 @@ var MinVersions = map[string]string{ "copilot": "1.0.0", // --output-format json envelope stable from 1.0.x } +// MinQuickCreateCLIVersion gates the agent-create (quick-create) flow against +// the multica CLI version reported by the daemon at registration time. The +// quick-create prompt that the agent runs depends on CLI behavior introduced +// after this version (attachment URL handling, no-retry semantics on +// `multica issue create` failure — see PR #1851); older daemons would either +// double-create issues or mishandle pasted screenshot URLs. Treated as a hard +// requirement: missing / unparsable / below this threshold all fail closed. +const MinQuickCreateCLIVersion = "0.2.20" + +// Errors returned by CheckMinCLIVersion. Callers branch on these to surface +// "needs upgrade" vs "version not reported" with the right user message. +var ( + ErrCLIVersionMissing = errors.New("multica CLI version not reported by daemon") + ErrCLIVersionTooOld = errors.New("multica CLI version is below required minimum") +) + +// 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. +func CheckMinCLIVersion(detected string) error { + d := strings.TrimSpace(detected) + if d == "" { + return ErrCLIVersionMissing + } + parsed, err := parseSemver(d) + if err != nil { + return ErrCLIVersionMissing + } + min, err := parseSemver(MinQuickCreateCLIVersion) + if err != nil { + // Misconfiguration in the constant itself — fail closed as missing. + return ErrCLIVersionMissing + } + if parsed.lessThan(min) { + return ErrCLIVersionTooOld + } + return nil +} + // semver holds a parsed semantic version (major.minor.patch). type semver struct { Major, Minor, Patch int