diff --git a/packages/core/runtimes/cli-version.ts b/packages/core/runtimes/cli-version.ts new file mode 100644 index 0000000000..c36ad67983 --- /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 59c6af048d..e16b10bef5 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.test.tsx b/packages/views/modals/quick-create-issue.test.tsx index 7814769710..786a6c2f38 100644 --- a/packages/views/modals/quick-create-issue.test.tsx +++ b/packages/views/modals/quick-create-issue.test.tsx @@ -30,6 +30,8 @@ vi.mock("@tanstack/react-query", () => ({ return { data: [{ id: "agent-1", name: "Bohan", archived_at: null, runtime_id: "runtime-1" }], }; + case "runtimes": + return { data: [{ id: "runtime-1", metadata: { cli_version: "1.2.3" } }] }; default: return { data: [] }; } @@ -73,6 +75,13 @@ vi.mock("@multica/core/auth", () => ({ (selector ? selector({ user: { id: "user-1" } }) : { user: { id: "user-1" } }), })); +vi.mock("@multica/core/runtimes", () => ({ + runtimeListOptions: () => ({ queryKey: ["runtimes"] }), + checkQuickCreateCliVersion: () => ({ state: "ok", min: "1.0.0" }), + readRuntimeCliVersion: () => "1.2.3", + MIN_QUICK_CREATE_CLI_VERSION: "1.0.0", +})); + vi.mock("@multica/core/hooks/use-file-upload", () => ({ useFileUpload: () => ({ uploadWithToast: vi.fn(), uploading: false }), })); diff --git a/packages/views/modals/quick-create-issue.tsx b/packages/views/modals/quick-create-issue.tsx index cd8a04ffd5..e834d0410f 100644 --- a/packages/views/modals/quick-create-issue.tsx +++ b/packages/views/modals/quick-create-issue.tsx @@ -20,6 +20,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 { formatShortcut, modKey, enterKey } from "@multica/core/platform"; import type { Agent } from "@multica/core/types"; @@ -105,6 +111,30 @@ 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. Skipped in dev builds so + // local source-built daemons (which report a `git describe` version + // like v0.2.15-N-gHASH that parses below the threshold) don't get + // blocked during testing — server has the matching APP_ENV bypass. + 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 = + process.env.NODE_ENV === "production" && versionCheck.state !== "ok"; + const initialPrompt = (data?.prompt as string) || promptDraft; // The editor is uncontrolled — we read the latest markdown via the ref at // submit/switch time. `hasContent` mirrors emptiness so the Create button @@ -138,7 +168,7 @@ export function AgentCreatePanel({ const submit = async () => { const md = editorRef.current?.getMarkdown()?.trim() ?? ""; - if (!md || !agentId || submitting || uploading) return; + if (!md || !agentId || submitting || versionBlocked || uploading) return; setSubmitting(true); setError(null); try { @@ -164,14 +194,32 @@ export function AgentCreatePanel({ } catch (e) { // 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 without leaving the flow. + // 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 { @@ -280,6 +328,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. */} @@ -345,7 +401,12 @@ export function AgentCreatePanel({