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>
This commit is contained in:
Jiayuan Zhang
2026-05-06 08:15:28 +08:00
parent 9dd90eef22
commit c45cc052e8
6 changed files with 261 additions and 4 deletions

View File

@@ -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<string, unknown> | undefined): string {
const v = metadata?.cli_version;
return typeof v === "string" ? v : "";
}

View File

@@ -6,3 +6,4 @@ export * from "./local-skills";
export * from "./types";
export * from "./derive-health";
export * from "./use-runtime-health";
export * from "./cli-version";

View File

@@ -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 }),
}));

View File

@@ -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({
</DropdownMenu>
</div>
{selectedAgent && versionBlocked && (
<div className="mx-5 mb-2 shrink-0 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
{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.`}
</div>
)}
{/* 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({
<Button
size="sm"
onClick={submit}
disabled={!hasContent || !agentId || submitting || uploading}
disabled={!hasContent || !agentId || submitting || versionBlocked || uploading}
title={
versionBlocked
? `Daemon CLI must be ≥ ${versionCheck.min}`
: undefined
}
className={justSent ? "min-w-28 !bg-emerald-600 !text-white" : "min-w-28"}
>
{submitting ? "Sending…" : uploading ? "Uploading…" : justSent ? (