mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
25 Commits
feature/pr
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a8655b43 | ||
|
|
9aee403ff9 | ||
|
|
7883fe7bd7 | ||
|
|
cbfb7d58b6 | ||
|
|
2832a06fe3 | ||
|
|
451715f5a1 | ||
|
|
fdf594155c | ||
|
|
c39470a53f | ||
|
|
e5dfb34a2a | ||
|
|
58549975e0 | ||
|
|
0bbc6bc1c5 | ||
|
|
beeb8bc107 | ||
|
|
5548d60dbb | ||
|
|
9fb25f4543 | ||
|
|
33140d4c5a | ||
|
|
9b8cc0870b | ||
|
|
ce40b66c60 | ||
|
|
56b49cb2a6 | ||
|
|
4353340ea6 | ||
|
|
91cbf32fd1 | ||
|
|
10b482fac2 | ||
|
|
0024208354 | ||
|
|
32a3a3543d | ||
|
|
e314badf18 | ||
|
|
6799458807 |
8
Makefile
8
Makefile
@@ -98,8 +98,12 @@ check-main:
|
||||
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
setup-worktree:
|
||||
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
|
||||
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
|
||||
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
|
||||
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
|
||||
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
|
||||
else \
|
||||
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
|
||||
fi
|
||||
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
start-worktree:
|
||||
|
||||
@@ -153,7 +153,8 @@ function LoginPageContent() {
|
||||
|
||||
await verifyCode(email, value);
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList);
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push(searchParams.get("next") || "/issues");
|
||||
} catch (err) {
|
||||
setError(
|
||||
|
||||
@@ -1437,7 +1437,7 @@ function AgentDetail({
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
|
||||
@@ -30,3 +30,12 @@
|
||||
background-color: var(--sidebar-accent);
|
||||
color: var(--sidebar-accent-foreground);
|
||||
}
|
||||
|
||||
/* Sonner toast: align icon to first line of text, not vertically centered */
|
||||
[data-sonner-toast] {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast] [data-icon] {
|
||||
margin-top: 2.5px;
|
||||
}
|
||||
|
||||
@@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
<CircleCheckIcon className="size-4 text-success" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
<InfoIcon className="size-4 text-info" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
<TriangleAlertIcon className="size-4 text-warning" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
<OctagonXIcon className="size-4 text-destructive" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<Loader2Icon className="size-4 animate-spin text-brand" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
|
||||
@@ -36,7 +36,6 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
@@ -56,7 +55,6 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
clearLoggedInCookie();
|
||||
|
||||
@@ -90,9 +90,17 @@ export const useInboxStore = create<InboxState>((set, get) => ({
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
|
||||
})),
|
||||
archive: (id) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, archived: true } : i)),
|
||||
})),
|
||||
set((s) => {
|
||||
const target = s.items.find((i) => i.id === id);
|
||||
const issueId = target?.issue_id;
|
||||
return {
|
||||
items: s.items.map((i) =>
|
||||
i.id === id || (issueId && i.issue_id === issueId)
|
||||
? { ...i, archived: true }
|
||||
: i,
|
||||
),
|
||||
};
|
||||
}),
|
||||
markAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
|
||||
import { Bot, ChevronRight, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
|
||||
import type { AgentTask } from "@/shared/types/agent";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { redactSecrets } from "../utils/redact";
|
||||
@@ -99,16 +100,20 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
|
||||
interface AgentLiveCardProps {
|
||||
issueId: string;
|
||||
agentName?: string;
|
||||
/** Scroll container ref — needed for sticky sentinel detection. */
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) {
|
||||
const { getActorName } = useActorName();
|
||||
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
|
||||
const [items, setItems] = useState<TimelineItem[]>([]);
|
||||
const [elapsed, setElapsed] = useState("");
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const seenSeqs = useRef(new Set<string>());
|
||||
|
||||
// Check for active task on mount
|
||||
@@ -215,12 +220,36 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
// Elapsed time
|
||||
useEffect(() => {
|
||||
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
|
||||
const ref = activeTask.started_at ?? activeTask.dispatched_at!;
|
||||
setElapsed(formatElapsed(ref));
|
||||
const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000);
|
||||
const startRef = activeTask.started_at ?? activeTask.dispatched_at!;
|
||||
setElapsed(formatElapsed(startRef));
|
||||
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeTask?.started_at, activeTask?.dispatched_at]);
|
||||
|
||||
// Sentinel pattern: detect when the card is scrolled past and becomes "stuck"
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const root = scrollContainerRef?.current;
|
||||
if (!sentinel || !root || !activeTask) {
|
||||
setIsStuck(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]) setIsStuck(!entries[0].isIntersecting);
|
||||
},
|
||||
{ root, threshold: 0, rootMargin: "-40px 0px 0px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollContainerRef, activeTask]);
|
||||
|
||||
const scrollToCard = useCallback(() => {
|
||||
sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, []);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
@@ -248,67 +277,104 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
if (!activeTask) return null;
|
||||
|
||||
const toolCount = items.filter((i) => i.type === "tool_use").length;
|
||||
const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-info/20 bg-info/5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-info/10 text-info shrink-0">
|
||||
<Bot className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
|
||||
<span className="truncate">{(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working</span>
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
|
||||
{toolCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
|
||||
</span>
|
||||
<>
|
||||
{/* Sentinel — zero-height element that IntersectionObserver watches */}
|
||||
<div ref={sentinelRef} className="mt-4 h-0 pointer-events-none" aria-hidden />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border transition-all duration-200",
|
||||
isStuck
|
||||
? "sticky top-4 z-10 shadow-md border-brand/30 bg-brand/10 backdrop-blur-md"
|
||||
: "border-info/20 bg-info/5",
|
||||
)}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
|
||||
title="Stop agent"
|
||||
>
|
||||
{cancelling ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
{activeTask.agent_id ? (
|
||||
<ActorAvatar actorType="agent" actorId={activeTask.agent_id} size={20} />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
<div className={cn(
|
||||
"flex items-center justify-center h-5 w-5 rounded-full shrink-0",
|
||||
isStuck ? "bg-brand/15 text-brand" : "bg-info/10 text-info",
|
||||
)}>
|
||||
<Bot className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline content */}
|
||||
{items.length > 0 && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
|
||||
))}
|
||||
|
||||
{!autoScroll && (
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
|
||||
<Loader2 className={cn("h-3 w-3 animate-spin shrink-0", isStuck ? "text-brand" : "text-info")} />
|
||||
<span className="truncate">{name} is working</span>
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
|
||||
{!isStuck && toolCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
|
||||
</span>
|
||||
)}
|
||||
{isStuck ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}}
|
||||
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
|
||||
onClick={scrollToCard}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
title="Scroll to live card"
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
Latest
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
|
||||
title="Stop agent"
|
||||
>
|
||||
{cancelling ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
)}
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline content — collapses when stuck */}
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isStuck ? "max-h-0 opacity-0" : "max-h-[20rem] opacity-100",
|
||||
)}
|
||||
>
|
||||
{items.length > 0 && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
|
||||
))}
|
||||
|
||||
{!autoScroll && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}}
|
||||
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
Latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
|
||||
{agents.filter((a) => !a.archived_at && canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
|
||||
@@ -742,9 +742,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{agents.length > 0 && (
|
||||
{agents.filter((a) => !a.archived_at).length > 0 && (
|
||||
<CommandGroup heading="Agents">
|
||||
{agents.map((a) => {
|
||||
{agents.filter((a) => !a.archived_at).map((a) => {
|
||||
const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id);
|
||||
const isSubbed = !!sub;
|
||||
return (
|
||||
@@ -771,12 +771,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
|
||||
{/* Agent live output */}
|
||||
<div className="mt-4">
|
||||
<AgentLiveCard
|
||||
issueId={id}
|
||||
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<AgentLiveCard
|
||||
issueId={id}
|
||||
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
|
||||
{/* Agent execution history */}
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -162,7 +162,7 @@ function ActorSubContent({
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
a.name.toLowerCase().includes(query),
|
||||
!a.archived_at && a.name.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
const isSelected = (type: "member" | "agent", id: string) =>
|
||||
|
||||
@@ -272,6 +272,39 @@ export const en: LandingDict = {
|
||||
title: "Changelog",
|
||||
subtitle: "New updates and improvements to Multica.",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.6",
|
||||
date: "2026-04-03",
|
||||
title: "Editor Overhaul & Agent Lifecycle",
|
||||
changes: [
|
||||
"Unified Tiptap editor with a single Markdown pipeline for editing and display",
|
||||
"Reliable Markdown paste, inline code spacing, and link styling",
|
||||
"Agent archive and restore — soft delete replaces hard delete",
|
||||
"Archived agents hidden from default agent list",
|
||||
"Skeleton loading states, error toasts, and confirmation dialogs across the app",
|
||||
"OpenCode added as a supported agent provider",
|
||||
"Reply-triggered agent tasks now inherit thread-root @mentions",
|
||||
"Granular real-time event handling for issues and inbox — no more full refetches",
|
||||
"Unified image upload flow for paste and button in the editor",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.5",
|
||||
date: "2026-04-02",
|
||||
title: "Mentions & Permissions",
|
||||
changes: [
|
||||
"@mention issues in comments with server-side auto-expansion",
|
||||
"@all mention to notify every workspace member",
|
||||
"Inbox auto-scrolls to the referenced comment from a notification",
|
||||
"Repositories extracted into a standalone settings tab",
|
||||
"CLI update support from the web runtime page and direct download for non-Homebrew installs",
|
||||
"CLI commands for viewing issue execution runs and run messages",
|
||||
"Agent permission model — owners and admins manage agents, members manage skills on their own agents",
|
||||
"Per-issue serial execution to prevent concurrent task collisions",
|
||||
"File upload now supports all file types",
|
||||
"README redesign with quickstart guide",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.4",
|
||||
date: "2026-04-01",
|
||||
|
||||
@@ -272,6 +272,39 @@ export const zh: LandingDict = {
|
||||
title: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.6",
|
||||
date: "2026-04-03",
|
||||
title: "编辑器重构与 Agent 生命周期",
|
||||
changes: [
|
||||
"统一 Tiptap 编辑器,编辑和展示共用单一 Markdown 渲染管线",
|
||||
"Markdown 粘贴、行内代码间距和链接样式修复",
|
||||
"Agent 支持归档和恢复——软删除替代硬删除",
|
||||
"默认列表隐藏已归档的 Agent",
|
||||
"全应用新增骨架屏加载态、错误提示和确认对话框",
|
||||
"新增 OpenCode 作为支持的 Agent 提供商",
|
||||
"回复触发的 Agent 任务自动继承主线程 @提及",
|
||||
"Issue 和收件箱实时事件细粒度处理,不再全量刷新",
|
||||
"编辑器中统一图片上传流程,支持粘贴和按钮上传",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.5",
|
||||
date: "2026-04-02",
|
||||
title: "提及与权限",
|
||||
changes: [
|
||||
"评论中支持 @提及 Issue,服务端自动展开",
|
||||
"支持 @all 提及工作区所有成员",
|
||||
"收件箱通知点击后自动滚动到对应评论",
|
||||
"仓库管理独立为设置页单独标签页",
|
||||
"支持从网页端运行时页面更新 CLI,非 Homebrew 安装支持直接下载更新",
|
||||
"新增 CLI 命令查看 Issue 执行记录和运行消息",
|
||||
"Agent 权限模型优化——所有者和管理员管理 Agent,成员可管理自己 Agent 的技能",
|
||||
"每个 Issue 串行执行,防止并发任务冲突",
|
||||
"文件上传支持所有文件类型",
|
||||
"README 重新设计,新增快速入门指南",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.4",
|
||||
date: "2026-04-01",
|
||||
|
||||
@@ -99,7 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
|
||||
const assigneeQuery = assigneeFilter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
|
||||
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
|
||||
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(assigneeQuery));
|
||||
|
||||
const assigneeLabel =
|
||||
assigneeType && assigneeId
|
||||
|
||||
@@ -233,7 +233,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
|
||||
clearWorkspace: () => {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -24,10 +26,124 @@ var agentListCmd = &cobra.Command{
|
||||
RunE: runAgentList,
|
||||
}
|
||||
|
||||
var agentGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get agent details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentGet,
|
||||
}
|
||||
|
||||
var agentCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new agent",
|
||||
RunE: runAgentCreate,
|
||||
}
|
||||
|
||||
var agentUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update an agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentUpdate,
|
||||
}
|
||||
|
||||
var agentArchiveCmd = &cobra.Command{
|
||||
Use: "archive <id>",
|
||||
Short: "Archive an agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentArchive,
|
||||
}
|
||||
|
||||
var agentRestoreCmd = &cobra.Command{
|
||||
Use: "restore <id>",
|
||||
Short: "Restore an archived agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentRestore,
|
||||
}
|
||||
|
||||
var agentTasksCmd = &cobra.Command{
|
||||
Use: "tasks <id>",
|
||||
Short: "List tasks for an agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentTasks,
|
||||
}
|
||||
|
||||
// Agent skills subcommands.
|
||||
|
||||
var agentSkillsCmd = &cobra.Command{
|
||||
Use: "skills",
|
||||
Short: "Manage agent skill assignments",
|
||||
}
|
||||
|
||||
var agentSkillsListCmd = &cobra.Command{
|
||||
Use: "list <agent-id>",
|
||||
Short: "List skills assigned to an agent",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentSkillsList,
|
||||
}
|
||||
|
||||
var agentSkillsSetCmd = &cobra.Command{
|
||||
Use: "set <agent-id>",
|
||||
Short: "Set skills for an agent (replaces all current assignments)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAgentSkillsSet,
|
||||
}
|
||||
|
||||
func init() {
|
||||
agentCmd.AddCommand(agentListCmd)
|
||||
agentCmd.AddCommand(agentGetCmd)
|
||||
agentCmd.AddCommand(agentCreateCmd)
|
||||
agentCmd.AddCommand(agentUpdateCmd)
|
||||
agentCmd.AddCommand(agentArchiveCmd)
|
||||
agentCmd.AddCommand(agentRestoreCmd)
|
||||
agentCmd.AddCommand(agentTasksCmd)
|
||||
agentCmd.AddCommand(agentSkillsCmd)
|
||||
|
||||
agentSkillsCmd.AddCommand(agentSkillsListCmd)
|
||||
agentSkillsCmd.AddCommand(agentSkillsSetCmd)
|
||||
|
||||
// agent list
|
||||
agentListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
agentListCmd.Flags().Bool("include-archived", false, "Include archived agents")
|
||||
|
||||
// agent get
|
||||
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// agent create
|
||||
agentCreateCmd.Flags().String("name", "", "Agent name (required)")
|
||||
agentCreateCmd.Flags().String("description", "", "Agent description")
|
||||
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
|
||||
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
|
||||
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
|
||||
agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace")
|
||||
agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks")
|
||||
agentCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// agent update
|
||||
agentUpdateCmd.Flags().String("name", "", "New name")
|
||||
agentUpdateCmd.Flags().String("description", "", "New description")
|
||||
agentUpdateCmd.Flags().String("instructions", "", "New instructions")
|
||||
agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID")
|
||||
agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string")
|
||||
agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace")
|
||||
agentUpdateCmd.Flags().String("status", "", "New status")
|
||||
agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks")
|
||||
agentUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// agent archive
|
||||
agentArchiveCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// agent restore
|
||||
agentRestoreCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// agent tasks
|
||||
agentTasksCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// agent skills list
|
||||
agentSkillsListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// agent skills set
|
||||
agentSkillsSetCmd.Flags().StringSlice("skill-ids", nil, "Skill IDs to assign (comma-separated)")
|
||||
agentSkillsSetCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
}
|
||||
|
||||
// resolveProfile returns the --profile flag value (empty string means default profile).
|
||||
@@ -90,6 +206,10 @@ func resolveWorkspaceID(cmd *cobra.Command) string {
|
||||
return cfg.WorkspaceID
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runAgentList(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
@@ -100,9 +220,16 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
|
||||
defer cancel()
|
||||
|
||||
var agents []map[string]any
|
||||
path := "/api/agents"
|
||||
params := url.Values{}
|
||||
if client.WorkspaceID != "" {
|
||||
path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
|
||||
params.Set("workspace_id", client.WorkspaceID)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("include-archived"); v {
|
||||
params.Set("include_archived", "true")
|
||||
}
|
||||
path := "/api/agents"
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
if err := client.GetJSON(ctx, path, &agents); err != nil {
|
||||
return fmt.Errorf("list agents: %w", err)
|
||||
@@ -113,20 +240,342 @@ func runAgentList(cmd *cobra.Command, _ []string) error {
|
||||
return cli.PrintJSON(os.Stdout, agents)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "STATUS", "RUNTIME"}
|
||||
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "ARCHIVED"}
|
||||
rows := make([][]string, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
archived := ""
|
||||
if v := strVal(a, "archived_at"); v != "" {
|
||||
archived = "yes"
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
strVal(a, "id"),
|
||||
strVal(a, "name"),
|
||||
strVal(a, "status"),
|
||||
strVal(a, "runtime_mode"),
|
||||
archived,
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentGet(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var agent map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
|
||||
return fmt.Errorf("get agent: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, agent)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "VISIBILITY", "DESCRIPTION"}
|
||||
rows := [][]string{{
|
||||
strVal(agent, "id"),
|
||||
strVal(agent, "name"),
|
||||
strVal(agent, "status"),
|
||||
strVal(agent, "runtime_mode"),
|
||||
strVal(agent, "visibility"),
|
||||
strVal(agent, "description"),
|
||||
}}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if name == "" {
|
||||
return fmt.Errorf("--name is required")
|
||||
}
|
||||
runtimeID, _ := cmd.Flags().GetString("runtime-id")
|
||||
if runtimeID == "" {
|
||||
return fmt.Errorf("--runtime-id is required")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"runtime_id": runtimeID,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("description"); v != "" {
|
||||
body["description"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("instructions"); v != "" {
|
||||
body["instructions"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("runtime-config") {
|
||||
v, _ := cmd.Flags().GetString("runtime-config")
|
||||
var rc any
|
||||
if err := json.Unmarshal([]byte(v), &rc); err != nil {
|
||||
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
|
||||
}
|
||||
body["runtime_config"] = rc
|
||||
}
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
v, _ := cmd.Flags().GetString("visibility")
|
||||
body["visibility"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("max-concurrent-tasks") {
|
||||
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
|
||||
body["max_concurrent_tasks"] = v
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/agents", body, &result); err != nil {
|
||||
return fmt.Errorf("create agent: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Agent created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]any{}
|
||||
if cmd.Flags().Changed("name") {
|
||||
v, _ := cmd.Flags().GetString("name")
|
||||
body["name"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
v, _ := cmd.Flags().GetString("description")
|
||||
body["description"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("instructions") {
|
||||
v, _ := cmd.Flags().GetString("instructions")
|
||||
body["instructions"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("runtime-id") {
|
||||
v, _ := cmd.Flags().GetString("runtime-id")
|
||||
body["runtime_id"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("runtime-config") {
|
||||
v, _ := cmd.Flags().GetString("runtime-config")
|
||||
var rc any
|
||||
if err := json.Unmarshal([]byte(v), &rc); err != nil {
|
||||
return fmt.Errorf("--runtime-config must be valid JSON: %w", err)
|
||||
}
|
||||
body["runtime_config"] = rc
|
||||
}
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
v, _ := cmd.Flags().GetString("visibility")
|
||||
body["visibility"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("status") {
|
||||
v, _ := cmd.Flags().GetString("status")
|
||||
body["status"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("max-concurrent-tasks") {
|
||||
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
|
||||
body["max_concurrent_tasks"] = v
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --visibility, --status, or --max-concurrent-tasks")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, &result); err != nil {
|
||||
return fmt.Errorf("update agent: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Agent updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentArchive(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/archive", nil, &result); err != nil {
|
||||
return fmt.Errorf("archive agent: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Agent archived: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentRestore(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/restore", nil, &result); err != nil {
|
||||
return fmt.Errorf("restore agent: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Agent restored: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentTasks(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var tasks []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/tasks", &tasks); err != nil {
|
||||
return fmt.Errorf("list agent tasks: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, tasks)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "ISSUE_ID", "STATUS", "CREATED_AT"}
|
||||
rows := make([][]string, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
rows = append(rows, []string{
|
||||
strVal(t, "id"),
|
||||
strVal(t, "issue_id"),
|
||||
strVal(t, "status"),
|
||||
strVal(t, "created_at"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent skills subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runAgentSkillsList(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var skills []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/skills", &skills); err != nil {
|
||||
return fmt.Errorf("list agent skills: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, skills)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "DESCRIPTION"}
|
||||
rows := make([][]string, 0, len(skills))
|
||||
for _, s := range skills {
|
||||
rows = append(rows, []string{
|
||||
strVal(s, "id"),
|
||||
strVal(s, "name"),
|
||||
strVal(s, "description"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentSkillsSet(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cmd.Flags().Changed("skill-ids") {
|
||||
return fmt.Errorf("--skill-ids is required (comma-separated skill IDs; use --skill-ids '' to clear all)")
|
||||
}
|
||||
skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids")
|
||||
// Allow passing empty string to clear all skills.
|
||||
cleanIDs := make([]string, 0, len(skillIDs))
|
||||
for _, id := range skillIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
cleanIDs = append(cleanIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"skill_ids": cleanIDs,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result json.RawMessage
|
||||
if err := client.PutJSON(ctx, "/api/agents/"+args[0]+"/skills", body, &result); err != nil {
|
||||
return fmt.Errorf("set agent skills: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
var pretty any
|
||||
json.Unmarshal(result, &pretty)
|
||||
return cli.PrintJSON(os.Stdout, pretty)
|
||||
}
|
||||
|
||||
fmt.Printf("Skills updated for agent %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func strVal(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
|
||||
@@ -162,6 +162,9 @@ func init() {
|
||||
|
||||
// issue comment list
|
||||
issueCommentListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
issueCommentListCmd.Flags().Int("limit", 0, "Maximum number of comments to return (0 = all)")
|
||||
issueCommentListCmd.Flags().Int("offset", 0, "Number of comments to skip")
|
||||
issueCommentListCmd.Flags().String("since", "", "Only return comments created after this timestamp (RFC3339)")
|
||||
|
||||
// issue runs
|
||||
issueRunsCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
@@ -536,9 +539,36 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
params := url.Values{}
|
||||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||||
params.Set("limit", fmt.Sprintf("%d", v))
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("offset"); v > 0 {
|
||||
params.Set("offset", fmt.Sprintf("%d", v))
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("since"); v != "" {
|
||||
params.Set("since", v)
|
||||
}
|
||||
|
||||
path := "/api/issues/" + args[0] + "/comments"
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
|
||||
var comments []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/issues/"+args[0]+"/comments", &comments); err != nil {
|
||||
return fmt.Errorf("list comments: %w", err)
|
||||
isPaginated := len(params) > 0
|
||||
if isPaginated {
|
||||
headers, getErr := client.GetJSONWithHeaders(ctx, path, &comments)
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("list comments: %w", getErr)
|
||||
}
|
||||
if total := headers.Get("X-Total-Count"); total != "" {
|
||||
fmt.Fprintf(os.Stderr, "Showing %d of %s comments.\n", len(comments), total)
|
||||
}
|
||||
} else {
|
||||
if err := client.GetJSON(ctx, path, &comments); err != nil {
|
||||
return fmt.Errorf("list comments: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
306
server/cmd/multica/cmd_runtime.go
Normal file
306
server/cmd/multica/cmd_runtime.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var runtimeCmd = &cobra.Command{
|
||||
Use: "runtime",
|
||||
Short: "Manage agent runtimes",
|
||||
}
|
||||
|
||||
var runtimeListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List runtimes in the workspace",
|
||||
RunE: runRuntimeList,
|
||||
}
|
||||
|
||||
var runtimeUsageCmd = &cobra.Command{
|
||||
Use: "usage <runtime-id>",
|
||||
Short: "Get token usage for a runtime",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRuntimeUsage,
|
||||
}
|
||||
|
||||
var runtimeActivityCmd = &cobra.Command{
|
||||
Use: "activity <runtime-id>",
|
||||
Short: "Get hourly task activity for a runtime",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRuntimeActivity,
|
||||
}
|
||||
|
||||
var runtimePingCmd = &cobra.Command{
|
||||
Use: "ping <runtime-id>",
|
||||
Short: "Ping a runtime to check connectivity",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRuntimePing,
|
||||
}
|
||||
|
||||
var runtimeUpdateCmd = &cobra.Command{
|
||||
Use: "update <runtime-id>",
|
||||
Short: "Initiate a CLI update on a runtime",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRuntimeUpdate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
runtimeCmd.AddCommand(runtimeListCmd)
|
||||
runtimeCmd.AddCommand(runtimeUsageCmd)
|
||||
runtimeCmd.AddCommand(runtimeActivityCmd)
|
||||
runtimeCmd.AddCommand(runtimePingCmd)
|
||||
runtimeCmd.AddCommand(runtimeUpdateCmd)
|
||||
|
||||
// runtime list
|
||||
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// runtime usage
|
||||
runtimeUsageCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
runtimeUsageCmd.Flags().Int("days", 90, "Number of days of usage data to retrieve (max 365)")
|
||||
|
||||
// runtime activity
|
||||
runtimeActivityCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// runtime ping
|
||||
runtimePingCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
runtimePingCmd.Flags().Bool("wait", false, "Wait for ping to complete (poll until done)")
|
||||
|
||||
// runtime update
|
||||
runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)")
|
||||
runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runRuntimeList(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var runtimes []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil {
|
||||
return fmt.Errorf("list runtimes: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, runtimes)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "MODE", "PROVIDER", "STATUS", "LAST_SEEN"}
|
||||
rows := make([][]string, 0, len(runtimes))
|
||||
for _, rt := range runtimes {
|
||||
rows = append(rows, []string{
|
||||
strVal(rt, "id"),
|
||||
strVal(rt, "name"),
|
||||
strVal(rt, "runtime_mode"),
|
||||
strVal(rt, "provider"),
|
||||
strVal(rt, "status"),
|
||||
strVal(rt, "last_seen_at"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRuntimeUsage(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
days, _ := cmd.Flags().GetInt("days")
|
||||
if days < 1 || days > 365 {
|
||||
return fmt.Errorf("--days must be between 1 and 365")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var usage []map[string]any
|
||||
path := fmt.Sprintf("/api/runtimes/%s/usage?days=%d", args[0], days)
|
||||
if err := client.GetJSON(ctx, path, &usage); err != nil {
|
||||
return fmt.Errorf("get runtime usage: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, usage)
|
||||
}
|
||||
|
||||
headers := []string{"DATE", "PROVIDER", "MODEL", "INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE"}
|
||||
rows := make([][]string, 0, len(usage))
|
||||
for _, u := range usage {
|
||||
rows = append(rows, []string{
|
||||
strVal(u, "date"),
|
||||
strVal(u, "provider"),
|
||||
strVal(u, "model"),
|
||||
strVal(u, "input_tokens"),
|
||||
strVal(u, "output_tokens"),
|
||||
strVal(u, "cache_read_tokens"),
|
||||
strVal(u, "cache_write_tokens"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRuntimeActivity(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var activity []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/activity", &activity); err != nil {
|
||||
return fmt.Errorf("get runtime activity: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, activity)
|
||||
}
|
||||
|
||||
headers := []string{"HOUR", "COUNT"}
|
||||
rows := make([][]string, 0, len(activity))
|
||||
for _, a := range activity {
|
||||
rows = append(rows, []string{
|
||||
strVal(a, "hour"),
|
||||
strVal(a, "count"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRuntimePing(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Initiate ping.
|
||||
var ping map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/ping", nil, &ping); err != nil {
|
||||
return fmt.Errorf("initiate ping: %w", err)
|
||||
}
|
||||
|
||||
wait, _ := cmd.Flags().GetBool("wait")
|
||||
if !wait {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, ping)
|
||||
}
|
||||
fmt.Printf("Ping initiated: %s (status: %s)\n", strVal(ping, "id"), strVal(ping, "status"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Poll until completed/failed/timeout.
|
||||
pingID := strVal(ping, "id")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timed out waiting for ping (last status: %s)", strVal(ping, "status"))
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
|
||||
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/ping/"+pingID, &ping); err != nil {
|
||||
return fmt.Errorf("get ping status: %w", err)
|
||||
}
|
||||
|
||||
status := strVal(ping, "status")
|
||||
if status == "completed" || status == "failed" || status == "timeout" {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, ping)
|
||||
}
|
||||
if status == "completed" {
|
||||
fmt.Printf("Ping completed in %sms\n", strVal(ping, "duration_ms"))
|
||||
} else {
|
||||
fmt.Printf("Ping %s: %s\n", status, strVal(ping, "error"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runRuntimeUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetVersion, _ := cmd.Flags().GetString("target-version")
|
||||
if targetVersion == "" {
|
||||
return fmt.Errorf("--target-version is required")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := map[string]any{
|
||||
"target_version": targetVersion,
|
||||
}
|
||||
|
||||
var update map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/update", body, &update); err != nil {
|
||||
return fmt.Errorf("initiate update: %w", err)
|
||||
}
|
||||
|
||||
wait, _ := cmd.Flags().GetBool("wait")
|
||||
if !wait {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, update)
|
||||
}
|
||||
fmt.Printf("Update initiated: %s (status: %s)\n", strVal(update, "id"), strVal(update, "status"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Poll until completed/failed/timeout.
|
||||
updateID := strVal(update, "id")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timed out waiting for update (last status: %s)", strVal(update, "status"))
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/update/"+updateID, &update); err != nil {
|
||||
return fmt.Errorf("get update status: %w", err)
|
||||
}
|
||||
|
||||
status := strVal(update, "status")
|
||||
if status == "completed" || status == "failed" || status == "timeout" {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, update)
|
||||
}
|
||||
if status == "completed" {
|
||||
fmt.Printf("Update completed: %s\n", strVal(update, "output"))
|
||||
} else {
|
||||
fmt.Printf("Update %s: %s\n", status, strVal(update, "error"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
450
server/cmd/multica/cmd_skill.go
Normal file
450
server/cmd/multica/cmd_skill.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var skillCmd = &cobra.Command{
|
||||
Use: "skill",
|
||||
Short: "Manage skills",
|
||||
}
|
||||
|
||||
var skillListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List skills in the workspace",
|
||||
RunE: runSkillList,
|
||||
}
|
||||
|
||||
var skillGetCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get skill details (includes files)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSkillGet,
|
||||
}
|
||||
|
||||
var skillCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new skill",
|
||||
RunE: runSkillCreate,
|
||||
}
|
||||
|
||||
var skillUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a skill",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSkillUpdate,
|
||||
}
|
||||
|
||||
var skillDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a skill",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSkillDelete,
|
||||
}
|
||||
|
||||
var skillImportCmd = &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Import a skill from a URL (clawhub.ai or skills.sh)",
|
||||
RunE: runSkillImport,
|
||||
}
|
||||
|
||||
// Skill file subcommands.
|
||||
|
||||
var skillFilesCmd = &cobra.Command{
|
||||
Use: "files",
|
||||
Short: "Manage skill files",
|
||||
}
|
||||
|
||||
var skillFilesListCmd = &cobra.Command{
|
||||
Use: "list <skill-id>",
|
||||
Short: "List files for a skill",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSkillFilesList,
|
||||
}
|
||||
|
||||
var skillFilesUpsertCmd = &cobra.Command{
|
||||
Use: "upsert <skill-id>",
|
||||
Short: "Create or update a skill file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSkillFilesUpsert,
|
||||
}
|
||||
|
||||
var skillFilesDeleteCmd = &cobra.Command{
|
||||
Use: "delete <skill-id> <file-id>",
|
||||
Short: "Delete a skill file",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runSkillFilesDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
skillCmd.AddCommand(skillListCmd)
|
||||
skillCmd.AddCommand(skillGetCmd)
|
||||
skillCmd.AddCommand(skillCreateCmd)
|
||||
skillCmd.AddCommand(skillUpdateCmd)
|
||||
skillCmd.AddCommand(skillDeleteCmd)
|
||||
skillCmd.AddCommand(skillImportCmd)
|
||||
skillCmd.AddCommand(skillFilesCmd)
|
||||
|
||||
skillFilesCmd.AddCommand(skillFilesListCmd)
|
||||
skillFilesCmd.AddCommand(skillFilesUpsertCmd)
|
||||
skillFilesCmd.AddCommand(skillFilesDeleteCmd)
|
||||
|
||||
// skill list
|
||||
skillListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// skill get
|
||||
skillGetCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// skill create
|
||||
skillCreateCmd.Flags().String("name", "", "Skill name (required)")
|
||||
skillCreateCmd.Flags().String("description", "", "Skill description")
|
||||
skillCreateCmd.Flags().String("content", "", "Skill content (SKILL.md body)")
|
||||
skillCreateCmd.Flags().String("config", "", "Skill config as JSON string")
|
||||
skillCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// skill update
|
||||
skillUpdateCmd.Flags().String("name", "", "New name")
|
||||
skillUpdateCmd.Flags().String("description", "", "New description")
|
||||
skillUpdateCmd.Flags().String("content", "", "New content")
|
||||
skillUpdateCmd.Flags().String("config", "", "New config as JSON string")
|
||||
skillUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// skill delete
|
||||
skillDeleteCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
|
||||
|
||||
// skill import
|
||||
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
|
||||
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// skill files list
|
||||
skillFilesListCmd.Flags().String("output", "table", "Output format: table or json")
|
||||
|
||||
// skill files upsert
|
||||
skillFilesUpsertCmd.Flags().String("path", "", "File path within the skill (required)")
|
||||
skillFilesUpsertCmd.Flags().String("content", "", "File content (required)")
|
||||
skillFilesUpsertCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runSkillList(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var skills []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/skills", &skills); err != nil {
|
||||
return fmt.Errorf("list skills: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, skills)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"}
|
||||
rows := make([][]string, 0, len(skills))
|
||||
for _, s := range skills {
|
||||
rows = append(rows, []string{
|
||||
strVal(s, "id"),
|
||||
strVal(s, "name"),
|
||||
strVal(s, "description"),
|
||||
strVal(s, "created_at"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var skill map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/skills/"+args[0], &skill); err != nil {
|
||||
return fmt.Errorf("get skill: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, skill)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"}
|
||||
rows := [][]string{{
|
||||
strVal(skill, "id"),
|
||||
strVal(skill, "name"),
|
||||
strVal(skill, "description"),
|
||||
strVal(skill, "created_at"),
|
||||
}}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillCreate(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if name == "" {
|
||||
return fmt.Errorf("--name is required")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("description"); v != "" {
|
||||
body["description"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("content"); v != "" {
|
||||
body["content"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("config") {
|
||||
v, _ := cmd.Flags().GetString("config")
|
||||
var config any
|
||||
if err := json.Unmarshal([]byte(v), &config); err != nil {
|
||||
return fmt.Errorf("--config must be valid JSON: %w", err)
|
||||
}
|
||||
body["config"] = config
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/skills", body, &result); err != nil {
|
||||
return fmt.Errorf("create skill: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Skill created: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]any{}
|
||||
if cmd.Flags().Changed("name") {
|
||||
v, _ := cmd.Flags().GetString("name")
|
||||
body["name"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
v, _ := cmd.Flags().GetString("description")
|
||||
body["description"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("content") {
|
||||
v, _ := cmd.Flags().GetString("content")
|
||||
body["content"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("config") {
|
||||
v, _ := cmd.Flags().GetString("config")
|
||||
var config any
|
||||
if err := json.Unmarshal([]byte(v), &config); err != nil {
|
||||
return fmt.Errorf("--config must be valid JSON: %w", err)
|
||||
}
|
||||
body["config"] = config
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --content, or --config")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PutJSON(ctx, "/api/skills/"+args[0], body, &result); err != nil {
|
||||
return fmt.Errorf("update skill: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Skill updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillDelete(cmd *cobra.Command, args []string) error {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if !yes {
|
||||
fmt.Printf("Are you sure you want to delete skill %s? This cannot be undone. [y/N] ", args[0])
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
if answer != "y" && answer != "yes" {
|
||||
fmt.Println("Aborted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]); err != nil {
|
||||
return fmt.Errorf("delete skill: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Skill deleted: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillImport(cmd *cobra.Command, _ []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
importURL, _ := cmd.Flags().GetString("url")
|
||||
if importURL == "" {
|
||||
return fmt.Errorf("--url is required")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"url": importURL,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
|
||||
return fmt.Errorf("import skill: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill file subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runSkillFilesList(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var files []map[string]any
|
||||
if err := client.GetJSON(ctx, "/api/skills/"+args[0]+"/files", &files); err != nil {
|
||||
return fmt.Errorf("list skill files: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, files)
|
||||
}
|
||||
|
||||
headers := []string{"ID", "PATH", "CREATED_AT", "UPDATED_AT"}
|
||||
rows := make([][]string, 0, len(files))
|
||||
for _, f := range files {
|
||||
rows = append(rows, []string{
|
||||
strVal(f, "id"),
|
||||
strVal(f, "path"),
|
||||
strVal(f, "created_at"),
|
||||
strVal(f, "updated_at"),
|
||||
})
|
||||
}
|
||||
cli.PrintTable(os.Stdout, headers, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillFilesUpsert(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filePath, _ := cmd.Flags().GetString("path")
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("--path is required")
|
||||
}
|
||||
content, _ := cmd.Flags().GetString("content")
|
||||
if content == "" {
|
||||
return fmt.Errorf("--content is required")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"path": filePath,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PutJSON(ctx, "/api/skills/"+args[0]+"/files", body, &result); err != nil {
|
||||
return fmt.Errorf("upsert skill file: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Skill file upserted: %s (%s)\n", strVal(result, "path"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSkillFilesDelete(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]+"/files/"+args[1]); err != nil {
|
||||
return fmt.Errorf("delete skill file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Skill file deleted: %s\n", args[1])
|
||||
return nil
|
||||
}
|
||||
@@ -36,6 +36,8 @@ func init() {
|
||||
rootCmd.AddCommand(repoCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
rootCmd.AddCommand(skillCmd)
|
||||
rootCmd.AddCommand(runtimeCmd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -402,3 +402,33 @@ func TestCommentTriggerCoalescing(t *testing.T) {
|
||||
t.Errorf("expected 1 pending task (coalescing), got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommentTriggerMentionAssigneeDoneIssue verifies that @mentioning the
|
||||
// assigned agent on a done issue still triggers execution. Previously the
|
||||
// assignee was unconditionally skipped in the mention path (assuming
|
||||
// on_comment handled it), but on_comment is suppressed for terminal statuses.
|
||||
func TestCommentTriggerMentionAssigneeDoneIssue(t *testing.T) {
|
||||
agentID := getAgentID(t)
|
||||
|
||||
// Create an issue assigned to the agent, then mark it done.
|
||||
issueID := createIssueAssignedToAgent(t, "Mention-assignee-done test", agentID)
|
||||
clearTasks(t, issueID) // clear any tasks from assignment
|
||||
resp := authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
|
||||
"status": "done",
|
||||
})
|
||||
resp.Body.Close()
|
||||
|
||||
t.Cleanup(func() {
|
||||
clearTasks(t, issueID)
|
||||
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
// @mention the assigned agent on the done issue — should trigger.
|
||||
content := fmt.Sprintf("[@Agent](mention://agent/%s) reopen this please", agentID)
|
||||
postComment(t, issueID, content, nil)
|
||||
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Errorf("expected 1 pending task after @mention of assignee on done issue, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,34 @@ func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// GetJSONWithHeaders performs a GET request, decodes the JSON response, and
|
||||
// returns the response headers. Useful when callers need header values like
|
||||
// X-Total-Count for pagination.
|
||||
func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any) (http.Header, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return resp.Header, err
|
||||
}
|
||||
}
|
||||
return resp.Header, nil
|
||||
}
|
||||
|
||||
// DeleteJSON performs a DELETE request.
|
||||
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)
|
||||
|
||||
@@ -47,7 +47,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("### Read\n")
|
||||
b.WriteString("- `multica issue get <id> --output json` — Get full issue details (title, description, status, priority, assignee)\n")
|
||||
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X] --output json` — List issues in workspace\n")
|
||||
b.WriteString("- `multica issue comment list <issue-id> --output json` — List all comments on an issue (includes id, parent_id for threading)\n")
|
||||
b.WriteString("- `multica issue comment list <issue-id> [--limit N] [--offset N] [--since <RFC3339>] --output json` — List comments on an issue (supports pagination; includes id, parent_id for threading)\n")
|
||||
b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n")
|
||||
b.WriteString("- `multica agent list --output json` — List agents in workspace\n")
|
||||
b.WriteString("- `multica issue runs <issue-id> --output json` — List all execution runs for an issue (status, timestamps, errors)\n")
|
||||
@@ -83,6 +83,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("**This task was triggered by a comment.** Your primary job is to respond.\n\n")
|
||||
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand the issue context\n", ctx.IssueID)
|
||||
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
|
||||
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
|
||||
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked\n", ctx.TriggerCommentID)
|
||||
fmt.Fprintf(&b, "4. Reply: `multica issue comment add %s --parent %s --content \"...\"`\n", ctx.IssueID, ctx.TriggerCommentID)
|
||||
b.WriteString("5. If the comment requests code changes or further work, do the work first, then reply with your results\n")
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -58,10 +60,81 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
comments, err := h.Queries.ListComments(r.Context(), db.ListCommentsParams{
|
||||
IssueID: issue.ID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
})
|
||||
// Parse optional pagination query params.
|
||||
q := r.URL.Query()
|
||||
var limit, offset int32
|
||||
var hasPagination bool
|
||||
if v := q.Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
writeError(w, http.StatusBadRequest, "invalid limit parameter")
|
||||
return
|
||||
}
|
||||
limit = int32(n)
|
||||
hasPagination = true
|
||||
}
|
||||
if v := q.Get("offset"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid offset parameter")
|
||||
return
|
||||
}
|
||||
offset = int32(n)
|
||||
hasPagination = true
|
||||
}
|
||||
|
||||
var sinceTime pgtype.Timestamptz
|
||||
if v := q.Get("since"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format")
|
||||
return
|
||||
}
|
||||
sinceTime = pgtype.Timestamptz{Time: t, Valid: true}
|
||||
}
|
||||
|
||||
var comments []db.Comment
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case sinceTime.Valid && hasPagination:
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
|
||||
IssueID: issue.ID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
CreatedAt: sinceTime,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
case sinceTime.Valid:
|
||||
// Apply a server-side cap to prevent unbounded result sets when
|
||||
// --since is used without --limit.
|
||||
comments, err = h.Queries.ListCommentsSincePaginated(r.Context(), db.ListCommentsSincePaginatedParams{
|
||||
IssueID: issue.ID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
CreatedAt: sinceTime,
|
||||
Limit: 500,
|
||||
Offset: 0,
|
||||
})
|
||||
hasPagination = true
|
||||
case hasPagination:
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
comments, err = h.Queries.ListCommentsPaginated(r.Context(), db.ListCommentsPaginatedParams{
|
||||
IssueID: issue.ID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
default:
|
||||
comments, err = h.Queries.ListComments(r.Context(), db.ListCommentsParams{
|
||||
IssueID: issue.ID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
||||
return
|
||||
@@ -80,6 +153,17 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
||||
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
|
||||
}
|
||||
|
||||
// Include total count in response header when paginating.
|
||||
if hasPagination {
|
||||
total, countErr := h.Queries.CountComments(r.Context(), db.CountCommentsParams{
|
||||
IssueID: issue.ID,
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
})
|
||||
if countErr == nil {
|
||||
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -202,8 +286,16 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
// is announcing to everyone, not specifically requesting work from the agent.
|
||||
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
|
||||
mentions := util.ParseMentions(content)
|
||||
// Filter out issue mentions — they are cross-references, not @people.
|
||||
filtered := mentions[:0]
|
||||
for _, m := range mentions {
|
||||
if m.Type != "issue" {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
mentions = filtered
|
||||
if len(mentions) == 0 {
|
||||
return false // No mentions — normal on_comment behavior
|
||||
return false // No mentions (or only issue refs) — normal on_comment behavior
|
||||
}
|
||||
// @all is a broadcast to all members — suppress agent trigger.
|
||||
if util.HasMentionAll(mentions) {
|
||||
@@ -297,9 +389,14 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
|
||||
}
|
||||
agentUUID := parseUUID(m.ID)
|
||||
// Prevent duplicate: skip if this agent is the issue's assignee
|
||||
// (already handled by the on_comment trigger above).
|
||||
if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
|
||||
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
|
||||
// (already handled by the on_comment trigger above) — but only
|
||||
// when the issue is in a non-terminal status where on_comment
|
||||
// will actually fire. For done/cancelled issues on_comment is
|
||||
// suppressed, so an explicit @mention must still go through.
|
||||
isAssignee := issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" &&
|
||||
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID
|
||||
isTerminal := issue.Status == "done" || issue.Status == "cancelled"
|
||||
if isAssignee && !isTerminal {
|
||||
continue
|
||||
}
|
||||
// Load the agent to check visibility, archive status, and trigger config.
|
||||
|
||||
@@ -143,10 +143,21 @@ func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Archive all sibling inbox items for the same issue (issue-level archive)
|
||||
if item.IssueID.Valid {
|
||||
h.Queries.ArchiveInboxByIssue(r.Context(), db.ArchiveInboxByIssueParams{
|
||||
WorkspaceID: item.WorkspaceID,
|
||||
RecipientType: item.RecipientType,
|
||||
RecipientID: item.RecipientID,
|
||||
IssueID: item.IssueID,
|
||||
})
|
||||
}
|
||||
|
||||
userID := requestUserID(r)
|
||||
workspaceID := uuidToString(item.WorkspaceID)
|
||||
h.publish(protocol.EventInboxArchived, workspaceID, "member", userID, map[string]any{
|
||||
"item_id": uuidToString(item.ID),
|
||||
"issue_id": uuidToPtr(item.IssueID),
|
||||
"recipient_id": uuidToString(item.RecipientID),
|
||||
})
|
||||
|
||||
|
||||
@@ -86,6 +86,16 @@ func TestCommentMentionsOthersButNotAssignee(t *testing.T) {
|
||||
content: fmt.Sprintf("[@All](mention://all/all) [@Agent](mention://agent/%s) fyi", agentAssigneeID),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "issue mention only → allow trigger (cross-reference, not @person)",
|
||||
content: "[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) is related",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "issue mention + other agent → suppress (agent mention matters)",
|
||||
content: fmt.Sprintf("[PAN-1](mention://issue/44c266e7-f6dd-4be3-9140-5ac40233f79c) cc [@Other](mention://agent/%s)", otherAgentID),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -11,6 +11,23 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countComments = `-- name: CountComments :one
|
||||
SELECT count(*) FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
type CountCommentsParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountComments(ctx context.Context, arg CountCommentsParams) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countComments, arg.IssueID, arg.WorkspaceID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createComment = `-- name: CreateComment :one
|
||||
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
@@ -155,6 +172,151 @@ func (q *Queries) ListComments(ctx context.Context, arg ListCommentsParams) ([]C
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listCommentsPaginated = `-- name: ListCommentsPaginated :many
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
type ListCommentsPaginatedParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCommentsPaginated(ctx context.Context, arg ListCommentsPaginatedParams) ([]Comment, error) {
|
||||
rows, err := q.db.Query(ctx, listCommentsPaginated,
|
||||
arg.IssueID,
|
||||
arg.WorkspaceID,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Comment{}
|
||||
for rows.Next() {
|
||||
var i Comment
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.AuthorType,
|
||||
&i.AuthorID,
|
||||
&i.Content,
|
||||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentID,
|
||||
&i.WorkspaceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listCommentsSince = `-- name: ListCommentsSince :many
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
type ListCommentsSinceParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCommentsSince(ctx context.Context, arg ListCommentsSinceParams) ([]Comment, error) {
|
||||
rows, err := q.db.Query(ctx, listCommentsSince, arg.IssueID, arg.WorkspaceID, arg.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Comment{}
|
||||
for rows.Next() {
|
||||
var i Comment
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.AuthorType,
|
||||
&i.AuthorID,
|
||||
&i.Content,
|
||||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentID,
|
||||
&i.WorkspaceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listCommentsSincePaginated = `-- name: ListCommentsSincePaginated :many
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $4 OFFSET $5
|
||||
`
|
||||
|
||||
type ListCommentsSincePaginatedParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListCommentsSincePaginated(ctx context.Context, arg ListCommentsSincePaginatedParams) ([]Comment, error) {
|
||||
rows, err := q.db.Query(ctx, listCommentsSincePaginated,
|
||||
arg.IssueID,
|
||||
arg.WorkspaceID,
|
||||
arg.CreatedAt,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Comment{}
|
||||
for rows.Next() {
|
||||
var i Comment
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.AuthorType,
|
||||
&i.AuthorID,
|
||||
&i.Content,
|
||||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentID,
|
||||
&i.WorkspaceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateComment = `-- name: UpdateComment :one
|
||||
UPDATE comment SET
|
||||
content = $2,
|
||||
|
||||
@@ -66,6 +66,31 @@ func (q *Queries) ArchiveCompletedInbox(ctx context.Context, arg ArchiveComplete
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const archiveInboxByIssue = `-- name: ArchiveInboxByIssue :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND issue_id = $4 AND archived = false
|
||||
`
|
||||
|
||||
type ArchiveInboxByIssueParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ArchiveInboxByIssue(ctx context.Context, arg ArchiveInboxByIssueParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, archiveInboxByIssue,
|
||||
arg.WorkspaceID,
|
||||
arg.RecipientType,
|
||||
arg.RecipientID,
|
||||
arg.IssueID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const archiveInboxItem = `-- name: ArchiveInboxItem :one
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE id = $1
|
||||
|
||||
@@ -3,6 +3,27 @@ SELECT * FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListCommentsPaginated :many
|
||||
SELECT * FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: ListCommentsSince :many
|
||||
SELECT * FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListCommentsSincePaginated :many
|
||||
SELECT * FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $4 OFFSET $5;
|
||||
|
||||
-- name: CountComments :one
|
||||
SELECT count(*) FROM comment
|
||||
WHERE issue_id = $1 AND workspace_id = $2;
|
||||
|
||||
-- name: GetComment :one
|
||||
SELECT * FROM comment
|
||||
WHERE id = $1;
|
||||
|
||||
@@ -32,6 +32,10 @@ UPDATE inbox_item SET archived = true
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ArchiveInboxByIssue :execrows
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND issue_id = $4 AND archived = false;
|
||||
|
||||
-- name: CountUnreadInbox :one
|
||||
SELECT count(*) FROM inbox_item
|
||||
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false;
|
||||
|
||||
Reference in New Issue
Block a user