mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
2 Commits
agent/lamb
...
feat/quick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20515e5582 | ||
|
|
4ed5f3ce50 |
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";
|
||||
|
||||
@@ -57,7 +57,10 @@ export function CreateIssueDialog({
|
||||
? cn(
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
|
||||
"!max-w-xl !w-full",
|
||||
// Width is capped; height is content-driven up to 80vh so a
|
||||
// pasted screenshot can't push the dialog past the viewport
|
||||
// (the inner editor area scrolls instead).
|
||||
"!max-w-xl !w-full !max-h-[80vh]",
|
||||
// Smooth size transition when switching modes — the manual mode
|
||||
// uses the same easing.
|
||||
"!transition-all !duration-300 !ease-out",
|
||||
|
||||
@@ -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,12 +298,24 @@ 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. */}
|
||||
{/* `flex-1 min-h-0 overflow-y-auto` so the editor area absorbs the
|
||||
remaining vertical space inside the (now max-bounded) DialogContent
|
||||
and scrolls internally. Without it, pasting an image expanded the
|
||||
editor unbounded and pushed the modal past the viewport. */}
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className="relative px-5 pb-3 min-h-[140px]"
|
||||
className="relative px-5 pb-3 flex-1 min-h-[140px] overflow-y-auto"
|
||||
>
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
@@ -293,7 +349,12 @@ export function AgentCreatePanel({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!hasContent || !agentId || submitting}
|
||||
disabled={!hasContent || !agentId || submitting || versionBlocked}
|
||||
title={
|
||||
versionBlocked
|
||||
? `Daemon CLI must be ≥ ${versionCheck.min}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{submitting ? "Sending…" : "Create"}
|
||||
</Button>
|
||||
|
||||
@@ -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": "<uuid>"
|
||||
// }
|
||||
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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user