mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
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:
61
packages/core/runtimes/cli-version.ts
Normal file
61
packages/core/runtimes/cli-version.ts
Normal 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 : "";
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from "./local-skills";
|
||||
export * from "./types";
|
||||
export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user