mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
4 Commits
agent/lamb
...
feat/quick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac03176e9 | ||
|
|
b3682eac4f | ||
|
|
bf9bd26dd9 | ||
|
|
06e2ed5347 |
@@ -151,12 +151,17 @@ export interface ImportStarterContentResponse {
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
// Raw decoded JSON body (when the server returned one). Carries structured
|
||||
// error fields like `code` so callers can branch on machine-readable
|
||||
// identifiers instead of pattern-matching the human-readable message.
|
||||
readonly body?: unknown;
|
||||
|
||||
constructor(message: string, status: number, statusText: string) {
|
||||
constructor(message: string, status: number, statusText: string, body?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +226,19 @@ export class ApiClient {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Reads the response body once for both human-readable error message and
|
||||
// structured fields. The Response stream can only be consumed once, so
|
||||
// both pieces have to come from a single read.
|
||||
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
const message = typeof data.error === "string" && data.error ? data.error : fallback;
|
||||
return { message, body: data };
|
||||
} catch {
|
||||
return { message: fallback, body: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
@@ -243,10 +261,10 @@ export class ApiClient {
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) this.handleUnauthorized();
|
||||
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const logLevel = res.status === 404 ? "warn" : "error";
|
||||
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new ApiError(message, res.status, res.statusText);
|
||||
throw new ApiError(message, res.status, res.statusText, body);
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
@@ -399,6 +417,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async createFeedback(data: {
|
||||
message: string;
|
||||
url?: string;
|
||||
|
||||
33
packages/core/issues/stores/quick-create-store.ts
Normal file
33
packages/core/issues/stores/quick-create-store.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
// Per-workspace memory of the last agent the user picked in the Quick Create
|
||||
// modal. Defaulted to that agent on next open so frequent users skip the
|
||||
// picker entirely. Persisted with the workspace-aware StateStorage so
|
||||
// switching workspaces shows the right default automatically. Per-user
|
||||
// scoping comes for free from localStorage being browser-profile-local —
|
||||
// matches how draft-store / issues-scope-store / comment-collapse-store
|
||||
// already namespace themselves.
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
}),
|
||||
{
|
||||
name: "multica_quick_create",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useQuickCreateStore.persist.rehydrate());
|
||||
@@ -5,6 +5,7 @@ import { create } from "zustand";
|
||||
type ModalType =
|
||||
| "create-workspace"
|
||||
| "create-issue"
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
|
||||
@@ -16,7 +16,9 @@ export type InboxItemType =
|
||||
| "task_failed"
|
||||
| "agent_blocked"
|
||||
| "agent_completed"
|
||||
| "reaction_added";
|
||||
| "reaction_added"
|
||||
| "quick_create_done"
|
||||
| "quick_create_failed";
|
||||
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
|
||||
@@ -20,6 +20,8 @@ const typeLabels: Record<InboxItemType, string> = {
|
||||
agent_blocked: "Agent blocked",
|
||||
agent_completed: "Agent completed",
|
||||
reaction_added: "Reacted",
|
||||
quick_create_done: "Quick create done",
|
||||
quick_create_failed: "Quick create failed",
|
||||
};
|
||||
|
||||
export { typeLabels };
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import {
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
@@ -257,7 +259,35 @@ export function InboxPage() {
|
||||
{selected.body}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{selected.type === "quick_create_failed" && selected.details?.original_prompt && (
|
||||
<div className="mt-4 rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Original input</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm">{selected.details.original_prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
{selected.type === "quick_create_failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Seed the legacy advanced form with the original prompt so the
|
||||
// user can recover their input in the full editor instead of
|
||||
// retyping. The agent picker hint becomes the assignee
|
||||
// candidate (still editable).
|
||||
const prompt = selected.details?.original_prompt ?? "";
|
||||
const agentId = selected.details?.agent_id;
|
||||
useIssueDraftStore.getState().setDraft({
|
||||
description: prompt,
|
||||
...(agentId
|
||||
? { assigneeType: "agent" as const, assigneeId: agentId }
|
||||
: {}),
|
||||
});
|
||||
useModalStore.getState().open("create-issue");
|
||||
}}
|
||||
>
|
||||
Edit as advanced form
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -418,23 +418,30 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
},
|
||||
});
|
||||
|
||||
// Global "C" shortcut to open create-issue modal (like Linear)
|
||||
// Global "C" shortcut: opens the quick-create modal by default; Shift+C
|
||||
// jumps straight to the legacy advanced form for users who want every
|
||||
// field. Both branches honor the same focus / open-modal guards.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
const isEditable =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
(e.target as HTMLElement)?.isContentEditable;
|
||||
if (isEditable) return;
|
||||
if (useModalStore.getState().modal) return;
|
||||
e.preventDefault();
|
||||
// Auto-fill project when on a project detail page
|
||||
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
|
||||
if (e.key !== "c" && e.key !== "C") return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
const isEditable =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
(e.target as HTMLElement)?.isContentEditable;
|
||||
if (isEditable) return;
|
||||
if (useModalStore.getState().modal) return;
|
||||
e.preventDefault();
|
||||
// Auto-fill project when on a project detail page (advanced form only —
|
||||
// quick-create lets the agent infer project from prompt).
|
||||
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
|
||||
if (e.shiftKey) {
|
||||
const data = projectMatch ? { project_id: projectMatch[1] } : undefined;
|
||||
useModalStore.getState().open("create-issue", data);
|
||||
} else {
|
||||
useModalStore.getState().open("quick-create-issue");
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
291
packages/views/modals/quick-create-issue.tsx
Normal file
291
packages/views/modals/quick-create-issue.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronRight, Sparkles, X as XIcon } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
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 { useModalStore } from "@multica/core/modals";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../common/actor-avatar";
|
||||
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
|
||||
// QuickCreateIssueModal — a streamlined create-issue UI: pick an agent, type
|
||||
// one line, submit. The agent translates the line into a `multica issue
|
||||
// create` call asynchronously; the modal closes immediately and the user is
|
||||
// notified via inbox when the agent finishes.
|
||||
export function QuickCreateIssueModal({
|
||||
onClose,
|
||||
data,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
data?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const workspaceName = useCurrentWorkspace()?.name;
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
|
||||
const memberRole = useMemo(
|
||||
() => members.find((m) => m.user_id === userId)?.role,
|
||||
[members, userId],
|
||||
);
|
||||
|
||||
// Visible = not archived AND assignable by this user.
|
||||
const visibleAgents = useMemo(
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) => !a.archived_at && canAssignAgent(a, userId, memberRole),
|
||||
),
|
||||
[agents, userId, memberRole],
|
||||
);
|
||||
|
||||
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
|
||||
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | undefined>(() => {
|
||||
const seed = (data?.agent_id as string) || lastAgentId || undefined;
|
||||
if (seed && visibleAgents.some((a) => a.id === seed)) return seed;
|
||||
return visibleAgents[0]?.id;
|
||||
});
|
||||
|
||||
// Re-seed once visible list resolves (queries may be empty on first render).
|
||||
useEffect(() => {
|
||||
if (agentId && visibleAgents.some((a) => a.id === agentId)) return;
|
||||
const seed = (data?.agent_id as string) || lastAgentId || undefined;
|
||||
if (seed && visibleAgents.some((a) => a.id === seed)) {
|
||||
setAgentId(seed);
|
||||
return;
|
||||
}
|
||||
const first = visibleAgents[0];
|
||||
if (first) setAgentId(first.id);
|
||||
}, [visibleAgents, agentId, data?.agent_id, lastAgentId]);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => visibleAgents.find((a) => a.id === agentId),
|
||||
[visibleAgents, agentId],
|
||||
);
|
||||
|
||||
const initialPrompt = (data?.prompt as string) || "";
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (!prompt.trim() || !agentId || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.quickCreateIssue({ agent_id: agentId, prompt: prompt.trim() });
|
||||
setLastAgentId(agentId);
|
||||
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
|
||||
duration: 4000,
|
||||
});
|
||||
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.
|
||||
if (e instanceof ApiError && e.body && typeof e.body === "object") {
|
||||
const body = e.body as { code?: string; reason?: string };
|
||||
if (body.code === "agent_unavailable") {
|
||||
setError(body.reason || "Agent is unavailable. Pick another agent.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError("Failed to submit. Try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Switch to the legacy advanced form, carrying the prompt over as the
|
||||
// description so the user doesn't lose what they typed. The picked agent
|
||||
// becomes the default assignee candidate (still editable). We seed the
|
||||
// shared issue-draft store directly because the legacy modal reads its
|
||||
// initial values from there rather than from `data`.
|
||||
const switchToAdvanced = () => {
|
||||
useIssueDraftStore.getState().setDraft({
|
||||
description: prompt,
|
||||
...(agentId
|
||||
? { assigneeType: "agent" as const, assigneeId: agentId }
|
||||
: {}),
|
||||
});
|
||||
useModalStore.getState().open("create-issue");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent
|
||||
finalFocus={false}
|
||||
showCloseButton={false}
|
||||
className={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",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">Quick create issue</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">Quick create</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={switchToAdvanced}
|
||||
className="text-xs px-2 py-1 rounded-sm opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
Advanced
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
Open the full form with all fields
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent picker */}
|
||||
<div className="px-5 pt-1 pb-2 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-sm px-1.5 py-1 -ml-1.5 hover:bg-accent/60"
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
<span>Created by</span>
|
||||
{selectedAgent ? (
|
||||
<span className="flex items-center gap-1.5 text-foreground">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={selectedAgent.id}
|
||||
size={16}
|
||||
disableHoverCard
|
||||
/>
|
||||
{selectedAgent.name}
|
||||
</span>
|
||||
) : (
|
||||
<span>Pick an agent…</span>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-64 max-h-72 overflow-y-auto">
|
||||
{visibleAgents.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
No agents available.
|
||||
</div>
|
||||
) : (
|
||||
visibleAgents.map((a: Agent) => (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setAgentId(a.id);
|
||||
setError(null);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={a.id}
|
||||
size={16}
|
||||
disableHoverCard
|
||||
/>
|
||||
<span className="flex-1 truncate">{a.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Prompt textarea */}
|
||||
<div className="px-5 pb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Describe the issue, e.g. "fix inbox loading slowness, assign to naiyuan, P1"'
|
||||
rows={5}
|
||||
className="w-full resize-none bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-5 pb-2 text-xs text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<span className="text-xs text-muted-foreground">⌘↵ to submit</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!prompt.trim() || !agentId || submitting}
|
||||
>
|
||||
{submitting ? "Sending…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { CreateWorkspaceModal } from "./create-workspace";
|
||||
import { CreateIssueModal } from "./create-issue";
|
||||
import { QuickCreateIssueModal } from "./quick-create-issue";
|
||||
import { CreateProjectModal } from "./create-project";
|
||||
import { FeedbackModal } from "./feedback";
|
||||
import { SetParentIssueModal } from "./set-parent-issue";
|
||||
@@ -20,6 +21,8 @@ export function ModalRegistry() {
|
||||
return <CreateWorkspaceModal onClose={close} />;
|
||||
case "create-issue":
|
||||
return <CreateIssueModal onClose={close} data={data} />;
|
||||
case "quick-create-issue":
|
||||
return <QuickCreateIssueModal onClose={close} data={data} />;
|
||||
case "create-project":
|
||||
return <CreateProjectModal onClose={close} />;
|
||||
case "feedback":
|
||||
|
||||
@@ -517,6 +517,18 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error {
|
||||
body["assignee_id"] = aID
|
||||
}
|
||||
|
||||
// Quick-create stamp: when the daemon sets MULTICA_QUICK_CREATE_TASK_ID
|
||||
// before invoking the agent, the agent's `multica issue create` call
|
||||
// inherits the env var and tags the new issue with origin_type=
|
||||
// quick_create + origin_id=<task_id>. The completion handler then
|
||||
// locates the issue deterministically by origin instead of "most
|
||||
// recent issue by this agent", which is racy when max_concurrent_tasks
|
||||
// > 1 and the agent is creating other issues in parallel.
|
||||
if taskID := os.Getenv("MULTICA_QUICK_CREATE_TASK_ID"); taskID != "" {
|
||||
body["origin_type"] = "quick_create"
|
||||
body["origin_id"] = taskID
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/issues", body, &result); err != nil {
|
||||
return fmt.Errorf("create issue: %w", err)
|
||||
|
||||
@@ -273,6 +273,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Get("/child-progress", h.ChildIssueProgress)
|
||||
r.Get("/", h.ListIssues)
|
||||
r.Post("/", h.CreateIssue)
|
||||
r.Post("/quick-create", h.QuickCreateIssue)
|
||||
r.Post("/batch-update", h.BatchUpdateIssues)
|
||||
r.Post("/batch-delete", h.BatchDeleteIssues)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
|
||||
@@ -1055,6 +1055,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
|
||||
AutopilotDescription: task.AutopilotDescription,
|
||||
AutopilotSource: task.AutopilotSource,
|
||||
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
|
||||
QuickCreatePrompt: task.QuickCreatePrompt,
|
||||
}
|
||||
|
||||
// Try to reuse the workdir from a previous task on the same (agent, issue) pair.
|
||||
@@ -1106,6 +1107,13 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
|
||||
if task.AutopilotID != "" {
|
||||
agentEnv["MULTICA_AUTOPILOT_ID"] = task.AutopilotID
|
||||
}
|
||||
// Quick-create marker — when set, the multica CLI's `issue create`
|
||||
// command stamps the new issue with origin_type=quick_create +
|
||||
// origin_id=<task_id> so the completion handler can find it
|
||||
// deterministically (see GetIssueByOrigin).
|
||||
if task.QuickCreatePrompt != "" {
|
||||
agentEnv["MULTICA_QUICK_CREATE_TASK_ID"] = task.ID
|
||||
}
|
||||
// Ensure the multica CLI is on PATH inside the agent's environment.
|
||||
// Some runtimes (e.g. Codex) run in an isolated sandbox that may not
|
||||
// inherit the daemon's PATH. Prepend the directory of the running
|
||||
|
||||
@@ -135,6 +135,9 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string {
|
||||
if ctx.AutopilotRunID != "" {
|
||||
return renderAutopilotContext(ctx)
|
||||
}
|
||||
if ctx.QuickCreatePrompt != "" {
|
||||
return renderQuickCreateContext(ctx)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
@@ -163,6 +166,35 @@ func renderIssueContext(provider string, ctx TaskContextForEnv) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderQuickCreateContext renders issue_context.md for quick-create tasks.
|
||||
// There is no issue yet, so we explicitly tell the agent NOT to call
|
||||
// `multica issue get` / `status` / `comment add` — those would either error
|
||||
// (empty IssueID) or silently target an unrelated issue.
|
||||
func renderQuickCreateContext(ctx TaskContextForEnv) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Quick Create\n\n")
|
||||
b.WriteString("**Trigger:** Quick-create modal\n\n")
|
||||
b.WriteString("There is NO existing Multica issue for this run. Translate the user input below into a single `multica issue create` invocation, then exit.\n\n")
|
||||
b.WriteString("## User input\n\n")
|
||||
b.WriteString("> ")
|
||||
b.WriteString(ctx.QuickCreatePrompt)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("## Rules\n\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation. No retries.\n")
|
||||
b.WriteString("- After it succeeds, print `Created MUL-<n>: <title>` and exit.\n")
|
||||
b.WriteString("- Do NOT run `multica issue get`, `multica issue status`, or `multica issue comment add` — there is nothing to query, transition, or comment on.\n")
|
||||
b.WriteString("- The platform writes the user's success/failure inbox notification automatically based on the CLI exit status.\n\n")
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
b.WriteString("## Agent Skills\n\n")
|
||||
b.WriteString("The following skills are available, but for quick-create they are usually unnecessary:\n\n")
|
||||
for _, skill := range ctx.AgentSkills {
|
||||
fmt.Fprintf(&b, "- **%s**\n", skill.Name)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderAutopilotContext(ctx TaskContextForEnv) string {
|
||||
var b strings.Builder
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ type TaskContextForEnv struct {
|
||||
AutopilotDescription string
|
||||
AutopilotSource string
|
||||
AutopilotTriggerPayload string
|
||||
QuickCreatePrompt string // non-empty for quick-create tasks
|
||||
}
|
||||
|
||||
// SkillContextForEnv represents a skill to be written into the execution environment.
|
||||
|
||||
@@ -134,6 +134,27 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("- If asked to perform actions (create issues, update status, etc.), use the appropriate CLI commands\n")
|
||||
b.WriteString("- If the task requires code changes, use `multica repo checkout <url>` to get the code first\n")
|
||||
b.WriteString("- Keep responses concise and direct\n\n")
|
||||
} else if ctx.QuickCreatePrompt != "" {
|
||||
// Quick-create task: no issue exists yet. The agent's only job is to
|
||||
// translate one line of natural language into a single
|
||||
// `multica issue create` call. Suppress the default assignment
|
||||
// workflow that would tell the agent to call `multica issue get` /
|
||||
// `multica issue status` / `multica issue comment add` against an
|
||||
// empty IssueID — those would either error or silently target the
|
||||
// wrong issue.
|
||||
b.WriteString("**This task was triggered by quick-create.** There is NO existing Multica issue. Translate the user's input into a single `multica issue create` invocation and exit.\n\n")
|
||||
fmt.Fprintf(&b, "User input:\n> %s\n\n", ctx.QuickCreatePrompt)
|
||||
b.WriteString("Field rules:\n")
|
||||
b.WriteString("- title: required, short imperative summary extracted from the user input.\n")
|
||||
b.WriteString("- description: optional; only include if the user supplied detail beyond the title.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low.\n")
|
||||
b.WriteString("- assignee: when the user says \"分给 X\" / \"assign to X\" / \"@X\", call `multica workspace members --output json` and find the matching member. On clean match, pass `--assignee <name>`. On no/ambiguous match, OMIT `--assignee` and append a final line to the description: `未识别 assignee: X`.\n")
|
||||
b.WriteString("- project / status: omit (defaults apply).\n\n")
|
||||
b.WriteString("Output rules:\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation.\n")
|
||||
b.WriteString("- After it succeeds, print exactly one line: `Created MUL-<n>: <title>` and exit.\n")
|
||||
b.WriteString("- Do NOT call `multica issue get`, `multica issue status`, or `multica issue comment add` for this task — there is no issue to query, transition, or comment on. The platform writes the user's success/failure inbox notification automatically based on whether `multica issue create` succeeded.\n")
|
||||
b.WriteString("- If the CLI returns an error, exit with that error as the only output. Do not retry.\n\n")
|
||||
} else if ctx.AutopilotRunID != "" {
|
||||
// Autopilot run_only task: no issue exists, so the agent must not
|
||||
// follow the assignment/comment workflow.
|
||||
@@ -238,9 +259,15 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("do NOT attempt to work around it. Instead, post a comment mentioning the workspace owner to request the missing functionality.\n\n")
|
||||
|
||||
b.WriteString("## Output\n\n")
|
||||
if ctx.AutopilotRunID != "" {
|
||||
switch {
|
||||
case ctx.AutopilotRunID != "":
|
||||
b.WriteString("This is a run-only autopilot task, so there may be no issue comment to post. Your final assistant output is captured automatically as the autopilot run result. Keep it concise and state the outcome.\n")
|
||||
} else {
|
||||
case ctx.QuickCreatePrompt != "":
|
||||
b.WriteString("This is a quick-create task. There is NO existing issue to comment on. Your final stdout is captured automatically and the platform writes the user's success/failure inbox notification based on whether `multica issue create` succeeded.\n\n")
|
||||
b.WriteString("- Do NOT call `multica issue comment add` — the issue you just created has no conversation context for this run.\n")
|
||||
b.WriteString("- Print exactly one final line: `Created MUL-<n>: <title>` after a successful `multica issue create`.\n")
|
||||
b.WriteString("- On CLI failure, exit with the CLI error as the only output. The platform translates that into a `quick_create_failed` inbox item carrying the original prompt for the user.\n")
|
||||
default:
|
||||
b.WriteString("⚠️ **Final results MUST be delivered via `multica issue comment add`.** The user does NOT see your terminal output, assistant chat text, or run logs — only comments on the issue. A task that finishes without a result comment is invisible to the user, even if the work itself was correct.\n\n")
|
||||
b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n")
|
||||
b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n")
|
||||
|
||||
@@ -20,6 +20,9 @@ func BuildPrompt(task Task) string {
|
||||
if task.AutopilotRunID != "" {
|
||||
return buildAutopilotPrompt(task)
|
||||
}
|
||||
if task.QuickCreatePrompt != "" {
|
||||
return buildQuickCreatePrompt(task)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
|
||||
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
|
||||
@@ -27,6 +30,32 @@ func BuildPrompt(task Task) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
|
||||
// user typed a single natural-language sentence in the create-issue modal;
|
||||
// the agent's only job is to translate it into one `multica issue create`
|
||||
// CLI invocation. No issue exists yet, so the agent must NOT call
|
||||
// `multica issue get` or attempt to comment — there's nothing to read or
|
||||
// reply to.
|
||||
func buildQuickCreatePrompt(task Task) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your only job is to translate the description into a single `multica issue create` command and run it.\n\n")
|
||||
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
|
||||
b.WriteString("Field rules:\n")
|
||||
b.WriteString("- title: required. A short, imperative summary extracted from the user input (e.g. \"fix inbox loading\"). Strip filler words.\n")
|
||||
b.WriteString("- description: optional. Include only if the user supplied detail beyond the title; otherwise omit. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low. If unspecified, omit.\n")
|
||||
b.WriteString("- assignee: when the user says \"分给 X\" / \"assign to X\" / \"@X\", call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
|
||||
b.WriteString("- project: omit. The platform will route the issue to the workspace default.\n")
|
||||
b.WriteString("- status: omit (defaults to `todo`).\n\n")
|
||||
b.WriteString("Output format:\n")
|
||||
b.WriteString("- Run exactly one `multica issue create` invocation.\n")
|
||||
b.WriteString("- After it succeeds, print exactly one line: `Created MUL-<n>: <title>` and exit. No commentary, no follow-up tool calls.\n")
|
||||
b.WriteString("- Do NOT call `multica issue get` or `multica issue comment add` for this task — there is no issue to query or comment on prior to creation.\n")
|
||||
b.WriteString("- If the CLI returns an error, exit with that error as the only output. The platform writes a failure notification automatically; do not retry.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildCommentPrompt constructs a prompt for comment-triggered tasks.
|
||||
// The triggering comment content is embedded directly so the agent cannot
|
||||
// miss it, even when stale output files exist in a reused workdir.
|
||||
|
||||
@@ -46,6 +46,7 @@ type Task struct {
|
||||
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
}
|
||||
|
||||
// AgentData holds agent details returned by the claim endpoint.
|
||||
|
||||
@@ -147,6 +147,7 @@ type AgentTaskResponse struct {
|
||||
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
}
|
||||
|
||||
// TaskAgentData holds agent info included in claim responses so the daemon
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
"github.com/multica-ai/multica/server/pkg/redact"
|
||||
@@ -887,6 +888,25 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-create task: no issue / chat / autopilot link — workspace and
|
||||
// prompt come from the task's context JSONB. Resolve workspace from
|
||||
// there so the isolation check below has something to compare.
|
||||
hasQuickCreate := false
|
||||
if task.Context != nil && !task.IssueID.Valid && !task.ChatSessionID.Valid && !task.AutopilotRunID.Valid {
|
||||
var qc service.QuickCreateContext
|
||||
if json.Unmarshal(task.Context, &qc) == nil && qc.Type == service.QuickCreateContextType {
|
||||
hasQuickCreate = true
|
||||
resp.QuickCreatePrompt = qc.Prompt
|
||||
resp.WorkspaceID = qc.WorkspaceID
|
||||
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(qc.WorkspaceID)); err == nil && ws.Repos != nil {
|
||||
var repos []RepoData
|
||||
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
|
||||
resp.Repos = repos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace isolation check: the daemon uses this response's workspace_id
|
||||
// as the only authority for MULTICA_WORKSPACE_ID in the agent env. An
|
||||
// empty value would make the CLI silently fall back to the user-global
|
||||
@@ -905,6 +925,7 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
"has_issue", task.IssueID.Valid,
|
||||
"has_chat", task.ChatSessionID.Valid,
|
||||
"has_autopilot_run", task.AutopilotRunID.Valid,
|
||||
"has_quick_create", hasQuickCreate,
|
||||
)
|
||||
if _, cerr := h.TaskService.CancelTask(r.Context(), task.ID); cerr != nil {
|
||||
slog.Error("task claim: cancel after workspace check failed",
|
||||
|
||||
@@ -848,6 +848,120 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// QuickCreateIssueRequest is the body for POST /api/issues/quick-create. The
|
||||
// user picks an agent in the modal and types one line of natural language;
|
||||
// the server validates the agent's reachability up front, queues a quick-
|
||||
// create task, and returns 202 immediately. The agent translates the prompt
|
||||
// into a `multica issue create` invocation in the background; success and
|
||||
// failure both surface as inbox notifications to the requester.
|
||||
type QuickCreateIssueRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
// QuickCreateIssueResponse echoes the queued task id so the frontend can
|
||||
// correlate the eventual inbox item, even though completion is fully async.
|
||||
type QuickCreateIssueResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
var req QuickCreateIssueRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
writeError(w, http.StatusBadRequest, "prompt is required")
|
||||
return
|
||||
}
|
||||
agentUUID, ok := parseUUIDOrBadRequest(w, req.AgentID, "agent_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
requesterID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requesterUUID, ok := parseUUIDOrBadRequest(w, requesterID, "requester_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse the same workspace-membership / archived / private-agent
|
||||
// ownership rules as `validateAssigneePair` so a user can't POST a
|
||||
// private agent_id they shouldn't be able to dispatch (the frontend
|
||||
// filters them out, but the handler is the trust boundary).
|
||||
if status, msg := h.validateAssigneePair(
|
||||
r.Context(), r, workspaceID,
|
||||
pgtype.Text{String: "agent", Valid: true},
|
||||
agentUUID,
|
||||
); status != 0 {
|
||||
writeError(w, status, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-load the agent for the runtime liveness check below. Safe by
|
||||
// construction: validateAssigneePair just confirmed it exists in this
|
||||
// workspace and the caller has visibility.
|
||||
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{
|
||||
ID: agentUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "agent not found")
|
||||
return
|
||||
}
|
||||
if !agent.RuntimeID.Valid {
|
||||
writeAgentUnavailable(w, "agent has no runtime")
|
||||
return
|
||||
}
|
||||
if !h.isRuntimeOnline(r.Context(), agent.RuntimeID) {
|
||||
writeAgentUnavailable(w, "agent's runtime is offline")
|
||||
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)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to enqueue quick-create task")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, QuickCreateIssueResponse{TaskID: uuidToString(task.ID)})
|
||||
}
|
||||
|
||||
// writeAgentUnavailable returns 422 with a stable error code so the modal
|
||||
// can show a "switch agent" hint without parsing the human-readable reason.
|
||||
func writeAgentUnavailable(w http.ResponseWriter, reason string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": "agent_unavailable",
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
// isRuntimeOnline returns true when the given runtime is currently
|
||||
// reachable (status == "online"). Quick-create rejects submissions whose
|
||||
// agent's runtime is offline so the user gets immediate feedback in the
|
||||
// modal instead of an inbox failure twenty seconds later.
|
||||
func (h *Handler) isRuntimeOnline(ctx context.Context, runtimeID pgtype.UUID) bool {
|
||||
rt, err := h.Queries.GetAgentRuntime(ctx, runtimeID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rt.Status == "online"
|
||||
}
|
||||
|
||||
type CreateIssueRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
@@ -859,6 +973,13 @@ type CreateIssueRequest struct {
|
||||
ProjectID *string `json:"project_id"`
|
||||
DueDate *string `json:"due_date"`
|
||||
AttachmentIDs []string `json:"attachment_ids,omitempty"`
|
||||
// OriginType / OriginID stamp the new issue with its provenance so
|
||||
// platform-internal flows can deterministically locate it later. Only
|
||||
// trusted callers should set these — currently the daemon CLI passes
|
||||
// them through for quick-create tasks (origin_type=quick_create,
|
||||
// origin_id=agent_task_queue.id).
|
||||
OriginType *string `json:"origin_type,omitempty"`
|
||||
OriginID *string `json:"origin_id,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -976,22 +1097,70 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
// Determine creator identity: agent (via X-Agent-ID header) or member.
|
||||
creatorType, actualCreatorID := h.resolveActor(r, creatorID, workspaceID)
|
||||
|
||||
issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: req.Title,
|
||||
Description: ptrToText(req.Description),
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
AssigneeType: assigneeType,
|
||||
AssigneeID: assigneeID,
|
||||
CreatorType: creatorType,
|
||||
CreatorID: parseUUID(actualCreatorID),
|
||||
ParentIssueID: parentIssueID,
|
||||
Position: 0,
|
||||
DueDate: dueDate,
|
||||
Number: issueNumber,
|
||||
ProjectID: projectID,
|
||||
})
|
||||
// Optional origin stamping (quick-create / autopilot). Only the
|
||||
// allowed origin types are accepted; anything else is rejected so a
|
||||
// rogue caller can't mint arbitrary origin labels. Both fields must
|
||||
// be provided together.
|
||||
var originType pgtype.Text
|
||||
var originID pgtype.UUID
|
||||
if req.OriginType != nil || req.OriginID != nil {
|
||||
if req.OriginType == nil || req.OriginID == nil {
|
||||
writeError(w, http.StatusBadRequest, "origin_type and origin_id must be provided together")
|
||||
return
|
||||
}
|
||||
switch *req.OriginType {
|
||||
case "quick_create":
|
||||
// Allowed — daemon CLI passes this through from a quick-create task.
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "unsupported origin_type")
|
||||
return
|
||||
}
|
||||
oid, ok := parseUUIDOrBadRequest(w, *req.OriginID, "origin_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
originType = pgtype.Text{String: *req.OriginType, Valid: true}
|
||||
originID = oid
|
||||
}
|
||||
|
||||
var issue db.Issue
|
||||
if originType.Valid {
|
||||
issue, err = qtx.CreateIssueWithOrigin(r.Context(), db.CreateIssueWithOriginParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: req.Title,
|
||||
Description: ptrToText(req.Description),
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
AssigneeType: assigneeType,
|
||||
AssigneeID: assigneeID,
|
||||
CreatorType: creatorType,
|
||||
CreatorID: parseUUID(actualCreatorID),
|
||||
ParentIssueID: parentIssueID,
|
||||
Position: 0,
|
||||
DueDate: dueDate,
|
||||
Number: issueNumber,
|
||||
ProjectID: projectID,
|
||||
OriginType: originType,
|
||||
OriginID: originID,
|
||||
})
|
||||
} else {
|
||||
issue, err = qtx.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Title: req.Title,
|
||||
Description: ptrToText(req.Description),
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
AssigneeType: assigneeType,
|
||||
AssigneeID: assigneeID,
|
||||
CreatorType: creatorType,
|
||||
CreatorID: parseUUID(actualCreatorID),
|
||||
ParentIssueID: parentIssueID,
|
||||
Position: 0,
|
||||
DueDate: dueDate,
|
||||
Number: issueNumber,
|
||||
ProjectID: projectID,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create issue: "+err.Error())
|
||||
|
||||
@@ -110,6 +110,68 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue,
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// QuickCreateContext is the JSON payload stored on a quick-create task's
|
||||
// context column. The daemon detects this variant via Type == "quick_create"
|
||||
// and switches to the quick-create prompt template; the completion path
|
||||
// uses RequesterID + WorkspaceID to write the inbox notification.
|
||||
type QuickCreateContext struct {
|
||||
Type string `json:"type"`
|
||||
Prompt string `json:"prompt"`
|
||||
RequesterID string `json:"requester_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
// QuickCreateContextType marks a task as a quick-create job.
|
||||
const QuickCreateContextType = "quick_create"
|
||||
|
||||
// EnqueueQuickCreateTask creates a queued task that has no issue / chat /
|
||||
// autopilot link — the user's natural-language prompt is stored in the
|
||||
// task's context JSONB and the agent is expected to translate it into a
|
||||
// `multica issue create` call. Pre-validates that the agent is reachable
|
||||
// (not archived, has a runtime) so the API can reject up-front rather than
|
||||
// queue a task no one will ever claim.
|
||||
func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, requesterID pgtype.UUID, agentID pgtype.UUID, prompt string) (db.AgentTaskQueue, error) {
|
||||
agent, err := s.Queries.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||
}
|
||||
if agent.ArchivedAt.Valid {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("agent is archived")
|
||||
}
|
||||
if !agent.RuntimeID.Valid {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
||||
}
|
||||
|
||||
payload := QuickCreateContext{
|
||||
Type: QuickCreateContextType,
|
||||
Prompt: prompt,
|
||||
RequesterID: util.UUIDToString(requesterID),
|
||||
WorkspaceID: util.UUIDToString(workspaceID),
|
||||
}
|
||||
contextJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("marshal quick-create context: %w", err)
|
||||
}
|
||||
|
||||
task, err := s.Queries.CreateQuickCreateTask(ctx, db.CreateQuickCreateTaskParams{
|
||||
AgentID: agentID,
|
||||
RuntimeID: agent.RuntimeID,
|
||||
Priority: priorityToInt("high"),
|
||||
Context: contextJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("create quick-create task: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("quick-create task enqueued",
|
||||
"task_id", util.UUIDToString(task.ID),
|
||||
"agent_id", util.UUIDToString(agentID),
|
||||
"requester_id", util.UUIDToString(requesterID),
|
||||
"workspace_id", util.UUIDToString(workspaceID),
|
||||
)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// EnqueueChatTask creates a queued task for a chat session.
|
||||
// Unlike issue tasks, chat tasks have no issue_id.
|
||||
func (s *TaskService) EnqueueChatTask(ctx context.Context, chatSession db.ChatSession) (db.AgentTaskQueue, error) {
|
||||
@@ -462,6 +524,16 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-create tasks: locate the issue the agent just created and push
|
||||
// an inbox confirmation to the requester. The agent has no issue / chat
|
||||
// link, so the regular completion paths above don't apply. We find the
|
||||
// new issue by querying for the most recent issue this agent created in
|
||||
// the requester's workspace since the task started — more robust than
|
||||
// parsing the agent's stdout for an identifier.
|
||||
if qc, ok := s.parseQuickCreateContext(task); ok {
|
||||
s.notifyQuickCreateCompleted(ctx, task, qc)
|
||||
}
|
||||
|
||||
// For chat tasks, save assistant reply and broadcast chat:done. The
|
||||
// resume pointer was already persisted inside the transaction above.
|
||||
if task.ChatSessionID.Valid {
|
||||
@@ -573,6 +645,16 @@ func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg,
|
||||
if errMsg != "" && task.IssueID.Valid && retried == nil {
|
||||
s.createAgentComment(ctx, task.IssueID, task.AgentID, redact.Text(errMsg), "system", task.TriggerCommentID)
|
||||
}
|
||||
|
||||
// Quick-create tasks: push a failure inbox notification to the
|
||||
// requester so they can either retry or fall back to the advanced form
|
||||
// without losing their original prompt. Skipped when an auto-retry is
|
||||
// pending — the new attempt will write its own outcome.
|
||||
if retried == nil {
|
||||
if qc, ok := s.parseQuickCreateContext(task); ok {
|
||||
s.notifyQuickCreateFailed(ctx, task, qc, errMsg)
|
||||
}
|
||||
}
|
||||
// Reconcile agent status
|
||||
s.ReconcileAgentStatus(ctx, task.AgentID)
|
||||
|
||||
@@ -1087,6 +1169,163 @@ func issueToMap(issue db.Issue, issuePrefix string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// parseQuickCreateContext returns the quick-create payload if the task's
|
||||
// context JSONB contains type == "quick_create"; otherwise the bool is
|
||||
// false so callers can short-circuit. Tasks linked to an issue / chat /
|
||||
// autopilot are never quick-create even if they happen to carry a
|
||||
// context blob, so those are filtered up front.
|
||||
func (s *TaskService) parseQuickCreateContext(task db.AgentTaskQueue) (QuickCreateContext, bool) {
|
||||
if task.IssueID.Valid || task.ChatSessionID.Valid || task.AutopilotRunID.Valid {
|
||||
return QuickCreateContext{}, false
|
||||
}
|
||||
if len(task.Context) == 0 {
|
||||
return QuickCreateContext{}, false
|
||||
}
|
||||
var qc QuickCreateContext
|
||||
if err := json.Unmarshal(task.Context, &qc); err != nil {
|
||||
return QuickCreateContext{}, false
|
||||
}
|
||||
if qc.Type != QuickCreateContextType {
|
||||
return QuickCreateContext{}, false
|
||||
}
|
||||
return qc, true
|
||||
}
|
||||
|
||||
// notifyQuickCreateCompleted writes a success inbox notification to the
|
||||
// requester pointing at the issue the agent just created. The issue is
|
||||
// stamped with origin_type=quick_create + origin_id=<task_id> by the
|
||||
// daemon-injected MULTICA_QUICK_CREATE_TASK_ID env var, so this lookup is
|
||||
// deterministic — robust against the same agent creating other issues in
|
||||
// parallel (e.g. assignment task running while max_concurrent_tasks > 1
|
||||
// permits another quick-create alongside it).
|
||||
func (s *TaskService) notifyQuickCreateCompleted(ctx context.Context, task db.AgentTaskQueue, qc QuickCreateContext) {
|
||||
requesterID, err := util.ParseUUID(qc.RequesterID)
|
||||
if err != nil {
|
||||
slog.Warn("quick-create completion: invalid requester id", "task_id", util.UUIDToString(task.ID), "error", err)
|
||||
return
|
||||
}
|
||||
workspaceID, err := util.ParseUUID(qc.WorkspaceID)
|
||||
if err != nil {
|
||||
slog.Warn("quick-create completion: invalid workspace id", "task_id", util.UUIDToString(task.ID), "error", err)
|
||||
return
|
||||
}
|
||||
issue, err := s.Queries.GetIssueByOrigin(ctx, db.GetIssueByOriginParams{
|
||||
WorkspaceID: workspaceID,
|
||||
OriginType: pgtype.Text{String: "quick_create", Valid: true},
|
||||
OriginID: task.ID,
|
||||
})
|
||||
if err != nil {
|
||||
// No issue created — agent ran to completion but the CLI call must
|
||||
// have failed. Surface as a failure inbox so the user sees something.
|
||||
slog.Warn("quick-create completion: no issue found, writing failure inbox",
|
||||
"task_id", util.UUIDToString(task.ID),
|
||||
"agent_id", util.UUIDToString(task.AgentID),
|
||||
"workspace_id", qc.WorkspaceID,
|
||||
)
|
||||
s.notifyQuickCreateFailed(ctx, task, qc, "agent finished without creating an issue")
|
||||
return
|
||||
}
|
||||
prefix := s.getIssuePrefix(workspaceID)
|
||||
identifier := fmt.Sprintf("%s-%d", prefix, issue.Number)
|
||||
details, _ := json.Marshal(map[string]any{
|
||||
"task_id": util.UUIDToString(task.ID),
|
||||
"agent_id": util.UUIDToString(task.AgentID),
|
||||
"issue_id": util.UUIDToString(issue.ID),
|
||||
"identifier": identifier,
|
||||
})
|
||||
item, err := s.Queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
|
||||
WorkspaceID: workspaceID,
|
||||
RecipientType: "member",
|
||||
RecipientID: requesterID,
|
||||
Type: "quick_create_done",
|
||||
Severity: "info",
|
||||
IssueID: issue.ID,
|
||||
Title: fmt.Sprintf("Created %s: %s", identifier, issue.Title),
|
||||
Body: pgtype.Text{},
|
||||
ActorType: pgtype.Text{String: "agent", Valid: true},
|
||||
ActorID: task.AgentID,
|
||||
Details: details,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("quick-create completion: inbox write failed", "task_id", util.UUIDToString(task.ID), "error", err)
|
||||
return
|
||||
}
|
||||
s.publishQuickCreateInbox(item, qc.WorkspaceID, util.UUIDToString(task.AgentID), issue.Status)
|
||||
}
|
||||
|
||||
// notifyQuickCreateFailed writes a failure inbox notification carrying the
|
||||
// original prompt + agent ID so the frontend can render an "Edit as
|
||||
// advanced form" entry that pre-fills the legacy create-issue modal
|
||||
// without asking the user to retype.
|
||||
func (s *TaskService) notifyQuickCreateFailed(ctx context.Context, task db.AgentTaskQueue, qc QuickCreateContext, errMsg string) {
|
||||
requesterID, err := util.ParseUUID(qc.RequesterID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
workspaceID, err := util.ParseUUID(qc.WorkspaceID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "Quick create did not finish successfully"
|
||||
}
|
||||
details, _ := json.Marshal(map[string]any{
|
||||
"task_id": util.UUIDToString(task.ID),
|
||||
"agent_id": util.UUIDToString(task.AgentID),
|
||||
"original_prompt": qc.Prompt,
|
||||
"error": redact.Text(errMsg),
|
||||
})
|
||||
item, err := s.Queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
|
||||
WorkspaceID: workspaceID,
|
||||
RecipientType: "member",
|
||||
RecipientID: requesterID,
|
||||
Type: "quick_create_failed",
|
||||
Severity: "action_required",
|
||||
IssueID: pgtype.UUID{},
|
||||
Title: "Quick create failed",
|
||||
Body: pgtype.Text{String: redact.Text(errMsg), Valid: true},
|
||||
ActorType: pgtype.Text{String: "agent", Valid: true},
|
||||
ActorID: task.AgentID,
|
||||
Details: details,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("quick-create failure: inbox write failed", "task_id", util.UUIDToString(task.ID), "error", err)
|
||||
return
|
||||
}
|
||||
s.publishQuickCreateInbox(item, qc.WorkspaceID, util.UUIDToString(task.AgentID), "")
|
||||
}
|
||||
|
||||
// publishQuickCreateInbox emits the WS event so the requester's inbox list
|
||||
// updates immediately. Mirrors the payload shape used by the other inbox
|
||||
// listeners (notification_listeners.go).
|
||||
func (s *TaskService) publishQuickCreateInbox(item db.InboxItem, workspaceID, agentID, issueStatus string) {
|
||||
resp := map[string]any{
|
||||
"id": util.UUIDToString(item.ID),
|
||||
"workspace_id": util.UUIDToString(item.WorkspaceID),
|
||||
"recipient_type": item.RecipientType,
|
||||
"recipient_id": util.UUIDToString(item.RecipientID),
|
||||
"type": item.Type,
|
||||
"severity": item.Severity,
|
||||
"issue_id": util.UUIDToPtr(item.IssueID),
|
||||
"title": item.Title,
|
||||
"body": util.TextToPtr(item.Body),
|
||||
"read": item.Read,
|
||||
"archived": item.Archived,
|
||||
"created_at": util.TimestampToString(item.CreatedAt),
|
||||
"actor_type": util.TextToPtr(item.ActorType),
|
||||
"actor_id": util.UUIDToPtr(item.ActorID),
|
||||
"details": json.RawMessage(item.Details),
|
||||
"issue_status": issueStatus,
|
||||
}
|
||||
s.Bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: workspaceID,
|
||||
ActorType: "agent",
|
||||
ActorID: agentID,
|
||||
Payload: map[string]any{"item": resp},
|
||||
})
|
||||
}
|
||||
|
||||
// agentToMap builds a simple map for broadcasting agent status updates.
|
||||
func agentToMap(a db.Agent) map[string]any {
|
||||
var rc any
|
||||
|
||||
3
server/migrations/060_issue_origin_quick_create.down.sql
Normal file
3
server/migrations/060_issue_origin_quick_create.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE issue DROP CONSTRAINT IF EXISTS issue_origin_type_check;
|
||||
ALTER TABLE issue ADD CONSTRAINT issue_origin_type_check
|
||||
CHECK (origin_type IN ('autopilot'));
|
||||
9
server/migrations/060_issue_origin_quick_create.up.sql
Normal file
9
server/migrations/060_issue_origin_quick_create.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Extend issue.origin_type to allow the quick-create flow to stamp issues with
|
||||
-- origin_type='quick_create' + origin_id=<agent_task_queue.id>. The completion
|
||||
-- handler uses this for a deterministic lookup of "the issue this quick-create
|
||||
-- task produced" instead of "the agent's most recent issue", which races against
|
||||
-- concurrent issue creates by the same agent (e.g. assignment task running
|
||||
-- alongside quick-create when max_concurrent_tasks > 1).
|
||||
ALTER TABLE issue DROP CONSTRAINT IF EXISTS issue_origin_type_check;
|
||||
ALTER TABLE issue ADD CONSTRAINT issue_origin_type_check
|
||||
CHECK (origin_type IN ('autopilot', 'quick_create'));
|
||||
@@ -285,6 +285,14 @@ WHERE id = (
|
||||
AND (
|
||||
(atq.issue_id IS NOT NULL AND active.issue_id = atq.issue_id)
|
||||
OR (atq.chat_session_id IS NOT NULL AND active.chat_session_id = atq.chat_session_id)
|
||||
OR (
|
||||
atq.issue_id IS NULL
|
||||
AND atq.chat_session_id IS NULL
|
||||
AND atq.autopilot_run_id IS NULL
|
||||
AND active.issue_id IS NULL
|
||||
AND active.chat_session_id IS NULL
|
||||
AND active.autopilot_run_id IS NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY atq.priority DESC, atq.created_at ASC
|
||||
@@ -299,6 +307,10 @@ RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, c
|
||||
// already dispatched or running. This allows different agents to work on the same
|
||||
// issue in parallel while preventing a single agent from running duplicate tasks.
|
||||
// Chat tasks (issue_id IS NULL) use chat_session_id for serialization instead.
|
||||
// Quick-create tasks have no issue / chat / autopilot link, so they serialize on
|
||||
// "any other quick-create-shaped task" (all four FKs NULL) for the same agent —
|
||||
// otherwise a user mashing the create button could fire concurrent quick-creates
|
||||
// whose completion lookup would race over "most recent issue by this agent".
|
||||
func (q *Queries) ClaimAgentTask(ctx context.Context, agentID pgtype.UUID) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, claimAgentTask, agentID)
|
||||
var i AgentTaskQueue
|
||||
@@ -550,6 +562,58 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createQuickCreateTask = `-- name: CreateQuickCreateTask :one
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, context)
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, last_heartbeat_at
|
||||
`
|
||||
|
||||
type CreateQuickCreateTaskParams struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
Context []byte `json:"context"`
|
||||
}
|
||||
|
||||
// Quick-create tasks have no issue / chat / autopilot link; the entire job
|
||||
// description (prompt, requester, workspace) lives in context JSONB. The
|
||||
// daemon detects this variant via context.type == "quick_create".
|
||||
func (q *Queries) CreateQuickCreateTask(ctx context.Context, arg CreateQuickCreateTaskParams) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, createQuickCreateTask,
|
||||
arg.AgentID,
|
||||
arg.RuntimeID,
|
||||
arg.Priority,
|
||||
arg.Context,
|
||||
)
|
||||
var i AgentTaskQueue
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AgentID,
|
||||
&i.IssueID,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.DispatchedAt,
|
||||
&i.StartedAt,
|
||||
&i.CompletedAt,
|
||||
&i.Result,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
&i.Context,
|
||||
&i.RuntimeID,
|
||||
&i.SessionID,
|
||||
&i.WorkDir,
|
||||
&i.TriggerCommentID,
|
||||
&i.ChatSessionID,
|
||||
&i.AutopilotRunID,
|
||||
&i.Attempt,
|
||||
&i.MaxAttempts,
|
||||
&i.ParentTaskID,
|
||||
&i.FailureReason,
|
||||
&i.LastHeartbeatAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createRetryTask = `-- name: CreateRetryTask :one
|
||||
INSERT INTO agent_task_queue (
|
||||
agent_id, runtime_id, issue_id, chat_session_id, autopilot_run_id,
|
||||
|
||||
@@ -363,6 +363,55 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getIssueByOrigin = `-- name: GetIssueByOrigin :one
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND origin_type = $2
|
||||
AND origin_id = $3
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetIssueByOriginParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
OriginType pgtype.Text `json:"origin_type"`
|
||||
OriginID pgtype.UUID `json:"origin_id"`
|
||||
}
|
||||
|
||||
// Finds the issue stamped with a specific (origin_type, origin_id) pair.
|
||||
// Used by quick-create completion to deterministically locate the issue
|
||||
// produced by a given agent_task_queue.id — robust against concurrent
|
||||
// issue creates by the same agent (assignment task + quick-create both
|
||||
// running with max_concurrent_tasks > 1).
|
||||
func (q *Queries) GetIssueByOrigin(ctx context.Context, arg GetIssueByOriginParams) (Issue, error) {
|
||||
row := q.db.QueryRow(ctx, getIssueByOrigin, arg.WorkspaceID, arg.OriginType, arg.OriginID)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Number,
|
||||
&i.ProjectID,
|
||||
&i.OriginType,
|
||||
&i.OriginID,
|
||||
&i.FirstExecutedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getIssueInWorkspace = `-- name: GetIssueInWorkspace :one
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
|
||||
@@ -69,6 +69,14 @@ INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority,
|
||||
VALUES ($1, $2, $3, 'queued', $4, sqlc.narg(trigger_comment_id))
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateQuickCreateTask :one
|
||||
-- Quick-create tasks have no issue / chat / autopilot link; the entire job
|
||||
-- description (prompt, requester, workspace) lives in context JSONB. The
|
||||
-- daemon detects this variant via context.type == "quick_create".
|
||||
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, context)
|
||||
VALUES ($1, $2, NULL, 'queued', $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateRetryTask :one
|
||||
-- Clones a parent task into a fresh queued attempt. Carries forward the
|
||||
-- agent's resume context (session_id/work_dir) so the child can continue
|
||||
@@ -136,6 +144,10 @@ WHERE id = $1;
|
||||
-- already dispatched or running. This allows different agents to work on the same
|
||||
-- issue in parallel while preventing a single agent from running duplicate tasks.
|
||||
-- Chat tasks (issue_id IS NULL) use chat_session_id for serialization instead.
|
||||
-- Quick-create tasks have no issue / chat / autopilot link, so they serialize on
|
||||
-- "any other quick-create-shaped task" (all four FKs NULL) for the same agent —
|
||||
-- otherwise a user mashing the create button could fire concurrent quick-creates
|
||||
-- whose completion lookup would race over "most recent issue by this agent".
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'dispatched', dispatched_at = now()
|
||||
WHERE id = (
|
||||
@@ -148,6 +160,14 @@ WHERE id = (
|
||||
AND (
|
||||
(atq.issue_id IS NOT NULL AND active.issue_id = atq.issue_id)
|
||||
OR (atq.chat_session_id IS NOT NULL AND active.chat_session_id = atq.chat_session_id)
|
||||
OR (
|
||||
atq.issue_id IS NULL
|
||||
AND atq.chat_session_id IS NULL
|
||||
AND atq.autopilot_run_id IS NULL
|
||||
AND active.issue_id IS NULL
|
||||
AND active.chat_session_id IS NULL
|
||||
AND active.autopilot_run_id IS NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY atq.priority DESC, atq.created_at ASC
|
||||
|
||||
@@ -100,6 +100,18 @@ SELECT * FROM issue
|
||||
WHERE parent_issue_id = $1
|
||||
ORDER BY position ASC, created_at DESC;
|
||||
|
||||
-- name: GetIssueByOrigin :one
|
||||
-- Finds the issue stamped with a specific (origin_type, origin_id) pair.
|
||||
-- Used by quick-create completion to deterministically locate the issue
|
||||
-- produced by a given agent_task_queue.id — robust against concurrent
|
||||
-- issue creates by the same agent (assignment task + quick-create both
|
||||
-- running with max_concurrent_tasks > 1).
|
||||
SELECT * FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND origin_type = $2
|
||||
AND origin_id = $3
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CountCreatedIssueAssignees :many
|
||||
-- Count assignees on issues created by a specific user.
|
||||
SELECT
|
||||
|
||||
Reference in New Issue
Block a user