Compare commits

..

8 Commits

Author SHA1 Message Date
Jiayuan Zhang
d37d7ae7ac refactor(quick-create): exempt git-describe daemons instead of env bypass
Replaces the per-environment bypass added in the previous commit with a
shared daemon-version signal. CheckMinCLIVersion / checkQuickCreateCliVersion
now treat any daemon whose CLI version matches the
`vX.Y.Z-N-gHASH[-dirty]` git-describe shape as OK; tagged releases keep
going through the normal min-version comparison.

Why: Emacs flagged that (a) NODE_ENV !== "production" also disables the
gate on staging and other non-prod deployments, undoing the protection
for the case the gate was originally written for, and (b) NODE_ENV (web
client) and APP_ENV (server) are not equivalent, so the modal pre-check
and server gate could disagree on the same request. Both go away when
the signal is intrinsic to the daemon's version string.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 08:24:38 +08:00
Jiayuan Zhang
c45cc052e8 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>
2026-05-06 08:15:28 +08:00
Jiayuan Zhang
9dd90eef22 refactor(quick-create): remove daemon CLI version gate
Local-source daemons report dev-suffixed versions (e.g.
v0.2.15-235-gdaf0e935) that the picker pre-check and server gate both
treat as too old, blocking quick-create during local testing.

Drops the gate end-to-end: removes MinQuickCreateCLIVersion +
CheckMinCLIVersion in pkg/agent, the checkQuickCreateDaemonVersion
handler and readRuntimeCLIVersion helper in handler/issue.go, and the
mirrored cli-version.ts plus the modal's pre-check, blocked-state UI,
and daemon_version_unsupported error branch.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 08:04:53 +08:00
Bohan Jiang
daf0e935f6 fix(views): show Ctrl+K / Ctrl+Enter on non-Mac platforms (#2060)
The sidebar search trigger, quick-create-issue modal, and feedback modal
hardcoded the Mac glyphs (⌘, ↵) for their keyboard hints, so Windows and
Linux users always saw Mac shortcuts even though the underlying handlers
already accept metaKey || ctrlKey.

Extract a small platform helper (isMac, modKey, enterKey, formatShortcut)
in packages/core/platform/keyboard.ts and route all four affected sites
(plus the editor bubble menu, which had the same logic inlined) through
it, so non-Mac users see Ctrl+K, Ctrl+Enter, etc.

Closes multica-ai/multica#2056
2026-05-04 21:26:00 +08:00
Bohan Jiang
5c42ed1649 fix(server): allow re-inviting after invitation expires (#2059)
The uniqueness check on workspace invitations only filtered by
status='pending', not by expires_at. Combined with the partial unique
index idx_invitation_unique_pending (also keyed only on status), a
past-due pending row permanently blocked re-inviting the same email.

Now, before creating a new invitation, the handler flips any past-due
pending row for the same (workspace_id, invitee_email) to 'expired',
freeing the unique slot. Also tightens GetPendingInvitationByEmail to
require expires_at > now(), matching the existing list queries.

Closes multica-ai/multica#2055.
2026-05-04 21:24:56 +08:00
Dingyj3178
a57dd76faf fix(views): improve mobile responsiveness for agents and settings (#2036)
* feat(agents): make agent detail page mobile responsive (#1)

Stack the inspector + overview pane vertically below md, switch the
shell to page-level scroll so the inspector flows naturally, give the
overview pane a min-h-[60vh] floor so tabs stay usable, and let the
5-tab nav scroll horizontally on narrow viewports.

* fix(settings): make Repositories tab and Settings shell mobile-responsive (#2)

The Settings shell used a fixed w-52 sidebar with no responsive behavior,
leaving almost no room for tab content on phone-width viewports. Stack the
nav above the content on mobile, scale inner padding, and let the
Repositories tab's input/button rows wrap rather than overflow.
2026-05-04 21:24:07 +08:00
Bohan Jiang
c24191a884 fix(editor): keep blank-line paste inside the code block (#2058)
Pasting `line1\n\nline2` while the caret was inside a code block ran the
text through the Markdown parser, which split on the blank line and tore
the code block open, dropping the trailing content into a sibling
paragraph.

Detect the codeBlock parent on `handlePaste` and insert the clipboard
text verbatim instead. Code blocks have `code: true`, so newlines stay
literal — exactly what users expect when pasting code or logs.

Closes #1982
2026-05-04 21:12:14 +08:00
Kagura
629f4136ac fix(codex): handle MCP elicitation server requests correctly (#1944)
* fix(codex): handle MCP elicitation server requests correctly

Fixes #1942.

handleServerRequest responded with {} to unrecognized Codex server
requests including mcpServer/elicitation/request. Codex 0.125+ expects
{action, content, _meta} for elicitation — the empty object causes a
deserialization error and the MCP tool call is reported as user-rejected.

Changes:
- Add mcpServer/elicitation/request case with correct response schema
- Add respondError helper for JSON-RPC error responses
- Return proper JSON-RPC method-not-found error for unknown server
  requests instead of silent empty object
- Add tests for MCP elicitation and unknown method handling

* fix: use cfg.Logger instead of global slog in codex handleServerRequest

Switch the unhandled-server-request warning from global slog.Warn to
c.cfg.Logger.Warn for consistency with all other log calls in codex.go.
This ensures the warning appears in daemon run-logs and per-task
pipelines where operators look during triage.
2026-05-04 21:05:37 +08:00
24 changed files with 446 additions and 36 deletions

View File

@@ -5,3 +5,4 @@ export { defaultStorage } from "./storage";
export { createPersistStorage } from "./persist-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
export { clearWorkspaceStorage } from "./storage-cleanup";
export { isMac, modKey, enterKey, formatShortcut } from "./keyboard";

View File

@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
beforeEach(() => {
vi.resetModules();
});
describe("keyboard platform helper", () => {
it("renders Mac symbols when navigator.platform is MacIntel", async () => {
vi.stubGlobal("navigator", { platform: "MacIntel" });
const mod = await import("./keyboard");
expect(mod.isMac).toBe(true);
expect(mod.modKey).toBe("⌘");
expect(mod.enterKey).toBe("↵");
expect(mod.formatShortcut(mod.modKey, "K")).toBe("⌘K");
expect(mod.formatShortcut(mod.modKey, mod.enterKey)).toBe("⌘↵");
});
it("renders Ctrl/Enter on Windows", async () => {
vi.stubGlobal("navigator", { platform: "Win32" });
const mod = await import("./keyboard");
expect(mod.isMac).toBe(false);
expect(mod.modKey).toBe("Ctrl");
expect(mod.enterKey).toBe("Enter");
expect(mod.formatShortcut(mod.modKey, "K")).toBe("Ctrl+K");
expect(mod.formatShortcut(mod.modKey, mod.enterKey)).toBe("Ctrl+Enter");
});
it("renders Ctrl/Enter on Linux", async () => {
vi.stubGlobal("navigator", { platform: "Linux x86_64" });
const mod = await import("./keyboard");
expect(mod.isMac).toBe(false);
expect(mod.modKey).toBe("Ctrl");
expect(mod.formatShortcut("Ctrl", "Shift", "P")).toBe("Ctrl+Shift+P");
});
it("falls back to non-Mac when navigator is unavailable (SSR)", async () => {
vi.stubGlobal("navigator", undefined);
const mod = await import("./keyboard");
expect(mod.isMac).toBe(false);
expect(mod.modKey).toBe("Ctrl");
});
});

View File

@@ -0,0 +1,24 @@
/**
* Coarse platform detection for keyboard-shortcut display.
*
* Eagerly evaluated at module load. On the server (no `navigator`) this
* resolves to `false`, so SSR always renders the non-Mac variant; on a
* real Mac the value is true after hydration. Acceptable trade-off for
* cosmetic shortcut hints — never gate functional behavior on this.
*/
export const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
/** Modifier key label — ⌘ on Mac, "Ctrl" elsewhere. */
export const modKey: string = isMac ? "⌘" : "Ctrl";
/** Enter / return key label — ↵ on Mac, "Enter" elsewhere. */
export const enterKey: string = isMac ? "↵" : "Enter";
/**
* Join key labels for display. Mac compresses combos with no separator
* ("⌘K", "⌘↵"); other platforms use "+" ("Ctrl+K", "Ctrl+Enter").
*/
export function formatShortcut(...keys: string[]): string {
return keys.join(isMac ? "" : "+");
}

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { checkQuickCreateCliVersion } from "./cli-version";
describe("checkQuickCreateCliVersion", () => {
it("returns ok for a tagged release at or above the minimum", () => {
expect(checkQuickCreateCliVersion("v0.2.20").state).toBe("ok");
expect(checkQuickCreateCliVersion("0.3.1").state).toBe("ok");
});
it("returns too_old for a tagged release below the minimum", () => {
expect(checkQuickCreateCliVersion("v0.2.15").state).toBe("too_old");
});
it("returns missing for empty or unparsable input", () => {
expect(checkQuickCreateCliVersion("").state).toBe("missing");
expect(checkQuickCreateCliVersion(undefined).state).toBe("missing");
expect(checkQuickCreateCliVersion("not-a-version").state).toBe("missing");
});
it("treats git-describe dev builds as ok regardless of base tag", () => {
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935").state).toBe("ok");
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935-dirty").state).toBe("ok");
expect(checkQuickCreateCliVersion("0.1.0-1-gabc1234").state).toBe("ok");
});
});

View File

@@ -24,6 +24,14 @@ export interface CliVersionCheck {
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
// Matches the `git describe --tags --always --dirty` output for a build past
// the latest tag, e.g. `v0.2.15-235-gdaf0e935` or `v0.2.15-235-gdaf0e935-dirty`.
// Daemons built from source (Makefile `make build` / `make daemon`) report this
// shape; tagged releases are bare semver. Treating dev-described daemons as OK
// is what keeps `pnpm dev:desktop` + `make daemon` unblocked without weakening
// the gate for staging or production users running stale stable releases.
const DEV_DESCRIBE_RE = /^v?\d+\.\d+\.\d+-\d+-g[0-9a-fA-F]+/;
function parseSemver(raw: string): [number, number, number] | null {
const m = SEMVER_RE.exec(raw.trim());
if (!m) return null;
@@ -40,9 +48,14 @@ function lessThan(a: [number, number, number], b: [number, number, number]) {
* 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.
* Dev-built daemons (git-describe shape) are always OK — the version string
* itself is the shared signal, so frontend and server agree by construction.
*/
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
const current = (detected ?? "").trim();
if (DEV_DESCRIBE_RE.test(current)) {
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
}
const parsed = current ? parseSemver(current) : null;
if (!parsed) {
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };

View File

@@ -93,7 +93,7 @@ export function AgentDetailInspector({
const isOnline = runtime?.status === "online";
return (
<aside className="flex h-full min-h-0 w-full flex-col overflow-y-auto rounded-lg border bg-background">
<aside className="flex w-full flex-col rounded-lg border bg-background md:h-full md:min-h-0 md:overflow-y-auto">
{/* Identity */}
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />

View File

@@ -204,7 +204,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
</div>
)}
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
<AgentDetailInspector
agent={agent}
runtime={runtime}
@@ -358,7 +358,7 @@ function DetailLoadingSkeleton() {
<PageHeader className="px-5">
<Skeleton className="h-5 w-48" />
</PageHeader>
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
<div className="flex flex-col gap-4 rounded-lg border p-5">
<Skeleton className="h-14 w-14 rounded-lg" />
<Skeleton className="h-5 w-40" />

View File

@@ -108,14 +108,14 @@ export function AgentOverviewPane({
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
<div className="flex shrink-0 items-center gap-0 border-b px-4">
<div className="flex min-h-[60vh] flex-col overflow-hidden rounded-lg border bg-background md:h-full md:min-h-0">
<div className="flex shrink-0 items-center gap-0 overflow-x-auto border-b px-2 md:px-4">
{detailTabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => requestTabChange(tab.id)}
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
activeTab === tab.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
@@ -204,6 +204,6 @@ export function AgentOverviewPane({
// list) still scrolls via the parent's overflow-y-auto.
function TabContent({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto flex h-full max-w-2xl flex-col p-6">{children}</div>
<div className="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
);
}

View File

@@ -34,6 +34,7 @@ import { posToDOMRect } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import { toast } from "sonner";
import { useCreateIssue } from "@multica/core/issues/mutations";
import { modKey } from "@multica/core/platform";
import { Toggle } from "@multica/ui/components/ui/toggle";
import { Separator } from "@multica/ui/components/ui/separator";
import {
@@ -86,10 +87,6 @@ function shouldShowBubbleMenu(editor: Editor): boolean {
return true;
}
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
// ---------------------------------------------------------------------------
// Mark Toggle Button
// ---------------------------------------------------------------------------
@@ -577,10 +574,10 @@ function EditorBubbleMenu({
) : (
<TooltipProvider delay={300}>
<div className="bubble-menu">
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} isActive={fmt.bold} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} isActive={fmt.italic} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} isActive={fmt.strike} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} isActive={fmt.code} />
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${modKey}+B`} isActive={fmt.bold} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${modKey}+I`} isActive={fmt.italic} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${modKey}+Shift+S`} isActive={fmt.strike} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${modKey}+E`} isActive={fmt.code} />
<Separator orientation="vertical" className="mx-0.5 h-5" />
<Tooltip>
<TooltipTrigger render={

View File

@@ -21,6 +21,7 @@ import { useCurrentWorkspace } from "@multica/core/paths";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { captureFeedbackOpened } from "@multica/core/analytics";
import { formatShortcut, modKey, enterKey } from "@multica/core/platform";
const MAX_MESSAGE_LEN = 10000;
@@ -120,7 +121,7 @@ export function FeedbackModal({ onClose }: { onClose: () => void }) {
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
{mutation.isPending ? "Sending…" : "Send feedback"}
<kbd className="ml-1 inline-flex h-4 items-center gap-0.5 rounded border border-border/50 bg-background/30 px-1 font-mono text-[10px] leading-none">
{formatShortcut(modKey, enterKey)}
</kbd>
</Button>
</div>

View File

@@ -230,7 +230,7 @@ describe("AgentCreatePanel", () => {
await user.type(editor, "New agent prompt");
expect(mockSetPrompt).toHaveBeenLastCalledWith("New agent prompt");
await user.click(screen.getByRole("button", { name: /Create \(⌘↵\)/i }));
await user.click(screen.getByRole("button", { name: /^Create \(/i }));
await waitFor(() => {
expect(mockQuickCreateIssue).toHaveBeenCalledWith({

View File

@@ -27,6 +27,7 @@ import {
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";
import { ActorAvatar } from "../common/actor-avatar";
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
@@ -115,7 +116,10 @@ export function AgentCreatePanel({
// 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.
// server re-validates as the trust boundary. Dev-built daemons
// (git-describe shape) are exempted inside checkQuickCreateCliVersion
// — frontend and server share the same signal there, so they agree by
// construction across web/desktop/staging without comparing env flags.
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const selectedRuntime = useMemo(
() =>
@@ -406,7 +410,7 @@ export function AgentCreatePanel({
>
{submitting ? "Sending…" : uploading ? "Uploading…" : justSent ? (
<span className="flex items-center gap-1"><Check className="size-3.5" />Sent</span>
) : "Create (⌘↵)"}
) : `Create (${formatShortcut(modKey, enterKey)})`}
</Button>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { Search } from "lucide-react";
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar";
import { isMac, formatShortcut, modKey } from "@multica/core/platform";
import { useSearchStore } from "./search-store";
export function SearchTrigger() {
@@ -13,7 +14,13 @@ export function SearchTrigger() {
<Search />
<span>Search...</span>
<kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>K
{isMac ? (
<>
<span className="text-xs">{modKey}</span>K
</>
) : (
formatShortcut(modKey, "K")
)}
</kbd>
</SidebarMenuButton>
);

View File

@@ -73,14 +73,14 @@ export function RepositoriesTab() {
</p>
{repos.map((repo, index) => (
<div key={index} className="flex gap-2">
<div key={index} className="flex items-start gap-2">
<Input
type="url"
value={repo.url}
onChange={(e) => handleRepoChange(index, e.target.value)}
disabled={!canManageWorkspace}
placeholder="https://git.example.com/org/repo.git"
className="flex-1 text-sm"
className="flex-1 min-w-0 text-sm"
/>
{canManageWorkspace && (
<Button
@@ -96,7 +96,7 @@ export function RepositoriesTab() {
))}
{canManageWorkspace && (
<div className="flex items-center justify-between pt-1">
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository

View File

@@ -43,11 +43,15 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
const workspaceName = useCurrentWorkspace()?.name;
return (
<Tabs defaultValue="profile" orientation="vertical" className="flex-1 min-h-0 gap-0">
{/* Left nav */}
<div className="w-52 shrink-0 border-r overflow-y-auto p-4">
<Tabs
defaultValue="profile"
orientation="vertical"
className="flex-1 min-h-0 gap-0 flex flex-col md:flex-row md:overflow-hidden overflow-y-auto"
>
{/* Left nav (stacks on top on mobile, sidebar on md+) */}
<div className="shrink-0 md:w-52 border-b md:border-b-0 md:border-r md:overflow-y-auto p-3 md:p-4">
<h1 className="text-sm font-semibold mb-4 px-2">Settings</h1>
<TabsList variant="line" className="flex-col items-stretch">
<TabsList variant="line" className="flex-col items-stretch w-full">
{/* My Account group */}
<span className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground">
My Account
@@ -79,8 +83,8 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
</div>
{/* Right content */}
<div className="flex-1 min-w-0 overflow-y-auto">
<div className="w-full max-w-3xl mx-auto p-6">
<div className="flex-1 min-w-0 md:overflow-y-auto">
<div className="w-full max-w-3xl mx-auto p-4 md:p-6">
<TabsContent value="profile"><AccountTab /></TabsContent>
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
<TabsContent value="notifications"><NotificationsTab /></TabsContent>

View File

@@ -95,7 +95,19 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) {
}
}
// Check if there is already a pending invitation.
// Drop any past-due pending invitations to 'expired' first. The partial unique
// index idx_invitation_unique_pending only filters by status = 'pending', so a
// stale row would otherwise block CreateInvitation below — see issue #2055.
if err := h.Queries.ExpireStalePendingInvitations(r.Context(), db.ExpireStalePendingInvitationsParams{
WorkspaceID: requester.WorkspaceID,
InviteeEmail: email,
}); err != nil {
slog.Warn("expire stale invitations failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID, "email", email)...)
writeError(w, http.StatusInternalServerError, "failed to create invitation")
return
}
// Check if there is still a live pending invitation.
_, err = h.Queries.GetPendingInvitationByEmail(r.Context(), db.GetPendingInvitationByEmailParams{
WorkspaceID: requester.WorkspaceID,
InviteeEmail: email,

View File

@@ -0,0 +1,114 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
const invitationTestEmail = "invitation-test@multica.ai"
func clearInvitationsForTestWorkspace(t *testing.T) {
t.Helper()
ctx := context.Background()
if _, err := testPool.Exec(ctx,
`DELETE FROM workspace_invitation WHERE workspace_id = $1`,
parseUUID(testWorkspaceID),
); err != nil {
t.Fatalf("clear invitations: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM workspace_invitation WHERE workspace_id = $1`,
parseUUID(testWorkspaceID),
)
})
}
// Sanity check: a fresh, live pending invitation must block re-invitation.
func TestCreateInvitation_BlocksWhilePending(t *testing.T) {
clearInvitationsForTestWorkspace(t)
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
Email: invitationTestEmail,
Role: "member",
})
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
testHandler.CreateInvitation(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("first invite: expected 201, got %d: %s", w.Code, w.Body.String())
}
req2 := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
Email: invitationTestEmail,
Role: "member",
})
req2 = withURLParam(req2, "id", testWorkspaceID)
w2 := httptest.NewRecorder()
testHandler.CreateInvitation(w2, req2)
if w2.Code != http.StatusConflict {
t.Fatalf("second invite: expected 409 while still pending, got %d: %s", w2.Code, w2.Body.String())
}
}
// Regression for issue #2055: an expired pending invitation must NOT block a
// new invitation to the same email. The stale row should be flipped to
// 'expired' and a fresh pending row should be created.
func TestCreateInvitation_AllowsAfterExpiry(t *testing.T) {
clearInvitationsForTestWorkspace(t)
ctx := context.Background()
var staleID string
if err := testPool.QueryRow(ctx, `
INSERT INTO workspace_invitation (
workspace_id, inviter_id, invitee_email, role, status, created_at, updated_at, expires_at
)
VALUES ($1, $2, $3, 'member', 'pending', now() - interval '10 days', now() - interval '10 days', now() - interval '3 days')
RETURNING id
`, parseUUID(testWorkspaceID), parseUUID(testUserID), invitationTestEmail).Scan(&staleID); err != nil {
t.Fatalf("seed expired invitation: %v", err)
}
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/members", CreateMemberRequest{
Email: invitationTestEmail,
Role: "member",
})
req = withURLParam(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
testHandler.CreateInvitation(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("re-invite after expiry: expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp InvitationResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ID == "" || resp.ID == staleID {
t.Fatalf("expected a new invitation row, got id=%q (stale=%q)", resp.ID, staleID)
}
var staleStatus string
if err := testPool.QueryRow(ctx,
`SELECT status FROM workspace_invitation WHERE id = $1`, staleID,
).Scan(&staleStatus); err != nil {
t.Fatalf("read stale row: %v", err)
}
if staleStatus != "expired" {
t.Fatalf("expected stale row to be 'expired', got %q", staleStatus)
}
var pendingCount int
if err := testPool.QueryRow(ctx, `
SELECT COUNT(*) FROM workspace_invitation
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending'
`, parseUUID(testWorkspaceID), invitationTestEmail).Scan(&pendingCount); err != nil {
t.Fatalf("count pending: %v", err)
}
if pendingCount != 1 {
t.Fatalf("expected exactly 1 pending invitation after re-invite, got %d", pendingCount)
}
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -13,8 +14,6 @@ import (
"time"
"unicode"
"errors"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
@@ -937,7 +936,9 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
// 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.
// the breakage as an inbox failure twenty seconds later. Dev-built
// daemons (git-describe shape) are exempted inside CheckMinCLIVersion
// so `make daemon` works without weakening staging or production.
if status, payload := h.checkQuickCreateDaemonVersion(r.Context(), agent.RuntimeID); status != 0 {
writeJSON(w, status, payload)
return

View File

@@ -552,6 +552,20 @@ func (c *codexClient) respond(id int, result any) {
_, _ = c.stdin.Write(data)
}
func (c *codexClient) respondError(id int, code int, message string) {
msg := map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{
"code": code,
"message": message,
},
}
data, _ := json.Marshal(msg)
data = append(data, '\n')
_, _ = c.stdin.Write(data)
}
func (c *codexClient) closeAllPending(err error) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -632,8 +646,11 @@ func (c *codexClient) handleServerRequest(raw map[string]json.RawMessage) {
c.respond(id, map[string]any{"decision": "accept"})
case "item/fileChange/requestApproval", "applyPatchApproval":
c.respond(id, map[string]any{"decision": "accept"})
case "mcpServer/elicitation/request":
c.respond(id, map[string]any{"action": "accept", "content": nil, "_meta": nil})
default:
c.respond(id, map[string]any{})
c.cfg.Logger.Warn("codex: unhandled server request", "method", method, "id", id)
c.respondError(id, -32601, fmt.Sprintf("unhandled server request: %s", method))
}
}

View File

@@ -168,6 +168,68 @@ func TestCodexHandleServerRequestFileChangeApproval(t *testing.T) {
}
}
func TestCodexHandleServerRequestMCPElicitation(t *testing.T) {
t.Parallel()
c, fs, _ := newTestCodexClient(t)
c.handleLine(`{"jsonrpc":"2.0","id":12,"method":"mcpServer/elicitation/request","params":{}}`)
lines := fs.Lines()
if len(lines) != 1 {
t.Fatalf("expected 1 response, got %d", len(lines))
}
var resp map[string]any
if err := json.Unmarshal([]byte(lines[0]), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["id"] != float64(12) {
t.Fatalf("expected id=12, got %v", resp["id"])
}
result := resp["result"].(map[string]any)
if result["action"] != "accept" {
t.Fatalf("expected action=accept, got %v", result["action"])
}
if _, ok := result["content"]; !ok {
t.Fatal("expected content key in response")
}
if _, ok := result["_meta"]; !ok {
t.Fatal("expected _meta key in response")
}
}
func TestCodexHandleServerRequestUnknownReturnsError(t *testing.T) {
t.Parallel()
c, fs, _ := newTestCodexClient(t)
c.handleLine(`{"jsonrpc":"2.0","id":13,"method":"some/unknown/method","params":{}}`)
lines := fs.Lines()
if len(lines) != 1 {
t.Fatalf("expected 1 response, got %d", len(lines))
}
var resp map[string]any
if err := json.Unmarshal([]byte(lines[0]), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["id"] != float64(13) {
t.Fatalf("expected id=13, got %v", resp["id"])
}
if resp["result"] != nil {
t.Fatalf("expected no result for error response, got %v", resp["result"])
}
errObj, ok := resp["error"].(map[string]any)
if !ok {
t.Fatal("expected error object in response")
}
if errObj["code"] != float64(-32601) {
t.Fatalf("expected error code -32601, got %v", errObj["code"])
}
}
func TestCodexLegacyEventTaskStarted(t *testing.T) {
t.Parallel()

View File

@@ -32,15 +32,30 @@ var (
ErrCLIVersionTooOld = errors.New("multica CLI version is below required minimum")
)
// devDescribeRe matches the `git describe --tags --always --dirty` output for
// a build past the latest tag, e.g. `v0.2.15-235-gdaf0e935` (optionally with a
// trailing `-dirty`). Daemons built from source (Makefile `make build` / `make
// daemon`) report this shape; tagged releases are bare semver. Treating dev-
// described daemons as OK keeps `make daemon` unblocked without weakening the
// gate for staging or production users running stale stable releases.
var devDescribeRe = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+-g[0-9a-fA-F]+`)
// 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.
//
// Dev-built daemons (git-describe shape) always pass — the version string
// itself is the shared signal, so the modal pre-check and this server gate
// agree by construction without needing to compare separate env flags.
func CheckMinCLIVersion(detected string) error {
d := strings.TrimSpace(detected)
if d == "" {
return ErrCLIVersionMissing
}
if devDescribeRe.MatchString(d) {
return nil
}
parsed, err := parseSemver(d)
if err != nil {
return ErrCLIVersionMissing

View File

@@ -1,6 +1,7 @@
package agent
import (
"errors"
"testing"
)
@@ -51,6 +52,32 @@ func TestSemverLessThan(t *testing.T) {
}
}
func TestCheckMinCLIVersion(t *testing.T) {
tests := []struct {
name string
input string
wantErr error
}{
{"tagged release at minimum", "v0.2.20", nil},
{"tagged release above minimum", "0.3.1", nil},
{"tagged release below minimum", "v0.2.15", ErrCLIVersionTooOld},
{"empty string", "", ErrCLIVersionMissing},
{"unparsable", "not-a-version", ErrCLIVersionMissing},
{"git-describe dev build past old tag", "v0.2.15-235-gdaf0e935", nil},
{"git-describe dirty dev build", "v0.2.15-235-gdaf0e935-dirty", nil},
{"git-describe dev build past current tag", "v0.2.20-3-gabc1234", nil},
}
for _, tt := range tests {
err := CheckMinCLIVersion(tt.input)
if tt.wantErr == nil && err != nil {
t.Errorf("%s: CheckMinCLIVersion(%q) = %v, want nil", tt.name, tt.input, err)
}
if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
t.Errorf("%s: CheckMinCLIVersion(%q) = %v, want %v", tt.name, tt.input, err, tt.wantErr)
}
}
}
func TestCheckMinVersion(t *testing.T) {
tests := []struct {
agentType string

View File

@@ -99,6 +99,29 @@ func (q *Queries) DeclineInvitation(ctx context.Context, id pgtype.UUID) (Worksp
return i, err
}
const expireStalePendingInvitations = `-- name: ExpireStalePendingInvitations :exec
UPDATE workspace_invitation
SET status = 'expired', updated_at = now()
WHERE workspace_id = $1
AND invitee_email = $2
AND status = 'pending'
AND expires_at <= now()
`
type ExpireStalePendingInvitationsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
InviteeEmail string `json:"invitee_email"`
}
// Mark any past-due pending invitations for (workspace_id, invitee_email) as expired,
// so the next CreateInvitation does not collide with the partial unique index
// idx_invitation_unique_pending (which is WHERE status = 'pending' and cannot
// itself reference now() in its predicate).
func (q *Queries) ExpireStalePendingInvitations(ctx context.Context, arg ExpireStalePendingInvitationsParams) error {
_, err := q.db.Exec(ctx, expireStalePendingInvitations, arg.WorkspaceID, arg.InviteeEmail)
return err
}
const getInvitation = `-- name: GetInvitation :one
SELECT id, workspace_id, inviter_id, invitee_email, invitee_user_id, role, status, created_at, updated_at, expires_at FROM workspace_invitation
WHERE id = $1
@@ -124,7 +147,7 @@ func (q *Queries) GetInvitation(ctx context.Context, id pgtype.UUID) (WorkspaceI
const getPendingInvitationByEmail = `-- name: GetPendingInvitationByEmail :one
SELECT id, workspace_id, inviter_id, invitee_email, invitee_user_id, role, status, created_at, updated_at, expires_at FROM workspace_invitation
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending'
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending' AND expires_at > now()
`
type GetPendingInvitationByEmailParams struct {

View File

@@ -47,4 +47,16 @@ WHERE id = $1 AND status = 'pending';
-- name: GetPendingInvitationByEmail :one
SELECT * FROM workspace_invitation
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending';
WHERE workspace_id = $1 AND invitee_email = $2 AND status = 'pending' AND expires_at > now();
-- name: ExpireStalePendingInvitations :exec
-- Mark any past-due pending invitations for (workspace_id, invitee_email) as expired,
-- so the next CreateInvitation does not collide with the partial unique index
-- idx_invitation_unique_pending (which is WHERE status = 'pending' and cannot
-- itself reference now() in its predicate).
UPDATE workspace_invitation
SET status = 'expired', updated_at = now()
WHERE workspace_id = $1
AND invitee_email = $2
AND status = 'pending'
AND expires_at <= now();