mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 16:39:33 +02:00
Compare commits
8 Commits
fix/code-b
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37d7ae7ac | ||
|
|
c45cc052e8 | ||
|
|
9dd90eef22 | ||
|
|
daf0e935f6 | ||
|
|
5c42ed1649 | ||
|
|
a57dd76faf | ||
|
|
c24191a884 | ||
|
|
629f4136ac |
@@ -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";
|
||||
|
||||
51
packages/core/platform/keyboard.test.ts
Normal file
51
packages/core/platform/keyboard.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
24
packages/core/platform/keyboard.ts
Normal file
24
packages/core/platform/keyboard.ts
Normal 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 ? "" : "+");
|
||||
}
|
||||
25
packages/core/runtimes/cli-version.test.ts
Normal file
25
packages/core/runtimes/cli-version.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
114
server/internal/handler/invitation_test.go
Normal file
114
server/internal/handler/invitation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user