Merge pull request #239 from multica-ai/forrestchang/jakarta
feat: agent management UI + task service layer + daemon protocol
This commit is contained in:
commit
fff7753a0c
24 changed files with 2809 additions and 220 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { use, useState, useEffect } from "react";
|
||||
import { use, useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
Send,
|
||||
UserCircle,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { Issue, Comment } from "@multica/types";
|
||||
import type { Issue, Comment, IssueAssigneeType } from "@multica/types";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "../_data/config";
|
||||
import { StatusIcon, PriorityIcon } from "../page";
|
||||
import { api } from "../../../../lib/api";
|
||||
|
|
@ -79,12 +81,19 @@ function ActorAvatar({
|
|||
function PropRow({
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[32px] items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex min-h-[32px] items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors ${
|
||||
onClick ? "cursor-pointer" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="w-20 shrink-0 text-[13px] text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5 text-[13px]">
|
||||
{children}
|
||||
|
|
@ -93,6 +102,151 @@ function PropRow({
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignee Picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AssigneePicker({
|
||||
issue,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
issue: Issue;
|
||||
onSelect: (type: IssueAssigneeType | null, id: string | null) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { members, agents } = useAuth();
|
||||
const [search, setSearch] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
a.name.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
const isSelected = (type: string, id: string) =>
|
||||
issue.assignee_type === type && issue.assignee_id === id;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-lg border bg-popover shadow-md"
|
||||
>
|
||||
<div className="p-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto px-1 pb-1">
|
||||
{/* Unassign option */}
|
||||
{issue.assignee_id && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onSelect(null, null)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs hover:bg-accent"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Unassign</span>
|
||||
</button>
|
||||
<div className="my-1 border-t mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Members
|
||||
</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
onClick={() => onSelect("member", m.user_id)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
|
||||
isSelected("member", m.user_id) ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{m.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium">{m.name}</div>
|
||||
</div>
|
||||
{isSelected("member", m.user_id) && (
|
||||
<span className="text-primary text-[10px] font-medium">Assigned</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Agents */}
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 mt-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Agents
|
||||
</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => onSelect("agent", a.id)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
|
||||
isSelected("agent", a.id) ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium">{a.name}</div>
|
||||
</div>
|
||||
{isSelected("agent", a.id) && (
|
||||
<span className="text-primary text-[10px] font-medium">Assigned</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -109,6 +263,7 @@ export default function IssueDetailPage({
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showAssigneePicker, setShowAssigneePicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIssue(null);
|
||||
|
|
@ -138,6 +293,31 @@ export default function IssueDetailPage({
|
|||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = async (
|
||||
type: IssueAssigneeType | null,
|
||||
assigneeId: string | null,
|
||||
) => {
|
||||
if (!issue) return;
|
||||
setShowAssigneePicker(false);
|
||||
// Optimistic update
|
||||
setIssue({
|
||||
...issue,
|
||||
assignee_type: type,
|
||||
assignee_id: assigneeId,
|
||||
});
|
||||
try {
|
||||
const updated = await api.updateIssue(id, {
|
||||
assignee_type: type,
|
||||
assignee_id: assigneeId,
|
||||
});
|
||||
setIssue(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to update assignee:", err);
|
||||
// Revert on error
|
||||
setIssue(issue);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
|
|
@ -259,20 +439,33 @@ export default function IssueDetailPage({
|
|||
<span>{priorityCfg.label}</span>
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Assignee">
|
||||
{issue.assignee_type && issue.assignee_id ? (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={18}
|
||||
/>
|
||||
<span>{getActorName(issue.assignee_type, issue.assignee_id)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
<div className="relative">
|
||||
<PropRow
|
||||
label="Assignee"
|
||||
onClick={() => setShowAssigneePicker(!showAssigneePicker)}
|
||||
>
|
||||
{issue.assignee_type && issue.assignee_id ? (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={18}
|
||||
/>
|
||||
<span>{getActorName(issue.assignee_type, issue.assignee_id)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</PropRow>
|
||||
|
||||
{showAssigneePicker && (
|
||||
<AssigneePicker
|
||||
issue={issue}
|
||||
onSelect={handleAssigneeChange}
|
||||
onClose={() => setShowAssigneePicker(false)}
|
||||
/>
|
||||
)}
|
||||
</PropRow>
|
||||
</div>
|
||||
|
||||
<PropRow label="Due date">
|
||||
{issue.due_date ? (
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const mockAgents: Agent[] = [
|
|||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
name: "Claude",
|
||||
description: "",
|
||||
avatar_url: null,
|
||||
status: "idle",
|
||||
runtime_mode: "cloud",
|
||||
|
|
@ -84,6 +85,9 @@ const mockAgents: Agent[] = [
|
|||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
skills: "",
|
||||
tools: [],
|
||||
triggers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const mockAgents: Agent[] = [
|
|||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
name: "Claude Agent",
|
||||
description: "",
|
||||
avatar_url: null,
|
||||
status: "idle",
|
||||
runtime_mode: "cloud",
|
||||
|
|
@ -51,6 +52,9 @@ export const mockAgents: Agent[] = [
|
|||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
skills: "",
|
||||
tools: [],
|
||||
triggers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import type {
|
|||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
InboxItem,
|
||||
Comment,
|
||||
Workspace,
|
||||
|
|
@ -151,6 +154,28 @@ export class ApiClient {
|
|||
return this.fetch(`/api/agents/${id}`);
|
||||
}
|
||||
|
||||
async createAgent(data: CreateAgentRequest): Promise<Agent> {
|
||||
return this.fetch("/api/agents", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<void> {
|
||||
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
|
||||
// Inbox
|
||||
async listInbox(): Promise<InboxItem[]> {
|
||||
return this.fetch("/api/inbox");
|
||||
|
|
|
|||
|
|
@ -4,10 +4,51 @@ export type AgentRuntimeMode = "local" | "cloud";
|
|||
|
||||
export type AgentVisibility = "workspace" | "private";
|
||||
|
||||
export type AgentTriggerType = "on_assign" | "scheduled";
|
||||
|
||||
export interface RuntimeDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
status: "online" | "offline";
|
||||
device_info: string;
|
||||
}
|
||||
|
||||
export interface AgentTool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
auth_type: "oauth" | "api_key" | "none";
|
||||
connected: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentTrigger {
|
||||
id: string;
|
||||
type: AgentTriggerType;
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
status: "queued" | "dispatched" | "running" | "completed" | "failed" | "cancelled";
|
||||
priority: number;
|
||||
dispatched_at: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
result: unknown;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
avatar_url: string | null;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
runtime_config: Record<string, unknown>;
|
||||
|
|
@ -15,6 +56,35 @@ export interface Agent {
|
|||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
owner_id: string | null;
|
||||
skills: string;
|
||||
tools: AgentTool[];
|
||||
triggers: AgentTrigger[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar_url?: string;
|
||||
runtime_mode?: AgentRuntimeMode;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
skills?: string;
|
||||
tools?: AgentTool[];
|
||||
triggers?: AgentTrigger[];
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
avatar_url?: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
skills?: string;
|
||||
tools?: AgentTool[];
|
||||
triggers?: AgentTrigger[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
|
||||
export type { Agent, AgentStatus, AgentRuntimeMode, AgentVisibility } from "./agent.js";
|
||||
export type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentRuntimeMode,
|
||||
AgentVisibility,
|
||||
AgentTriggerType,
|
||||
AgentTool,
|
||||
AgentTrigger,
|
||||
AgentTask,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
} from "./agent.js";
|
||||
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace.js";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox.js";
|
||||
export type { Comment, CommentType, CommentAuthorType } from "./comment.js";
|
||||
|
|
|
|||
|
|
@ -62,13 +62,45 @@ func main() {
|
|||
// Create some agents
|
||||
agents := []struct {
|
||||
name string
|
||||
description string
|
||||
runtimeMode string
|
||||
status string
|
||||
skills string
|
||||
tools string
|
||||
triggers string
|
||||
}{
|
||||
{"Claude-1", "cloud", "idle"},
|
||||
{"Claude-2", "cloud", "working"},
|
||||
{"Local Agent", "local", "offline"},
|
||||
{"Code Review Bot", "cloud", "idle"},
|
||||
{
|
||||
"Deep Research Agent",
|
||||
"Performs deep research on topics using web search and analysis",
|
||||
"local", "idle",
|
||||
"# Deep Research Agent\n\nYou are a research agent that performs thorough analysis on assigned topics.\n\n## Workflow\n1. Break down the research question into sub-questions\n2. Use web search to gather information from multiple sources\n3. Cross-reference and validate findings\n4. Synthesize a comprehensive report\n5. Post the report as a comment on the issue\n\n## Output Format\nAlways produce a structured report with:\n- Executive Summary\n- Key Findings\n- Sources\n- Recommendations",
|
||||
`[{"id":"tool-1","name":"Google Search","description":"Search the web for information","auth_type":"api_key","connected":true,"config":{}},{"id":"tool-2","name":"Web Scraper","description":"Extract content from web pages","auth_type":"none","connected":true,"config":{}}]`,
|
||||
`[{"id":"trigger-1","type":"on_assign","enabled":true,"config":{}}]`,
|
||||
},
|
||||
{
|
||||
"Code Review Bot",
|
||||
"Reviews pull requests and provides feedback on code quality",
|
||||
"cloud", "idle",
|
||||
"# Code Review Bot\n\nYou review code changes and provide constructive feedback.\n\n## Review Criteria\n- Code correctness and logic\n- Performance implications\n- Security vulnerabilities\n- Code style and readability\n- Test coverage\n\n## Process\n1. Read the issue description for context\n2. Analyze code changes\n3. Post review comments on specific lines\n4. Provide an overall summary",
|
||||
`[{"id":"tool-3","name":"GitHub","description":"Access GitHub repositories and PRs","auth_type":"oauth","connected":true,"config":{}}]`,
|
||||
`[{"id":"trigger-2","type":"on_assign","enabled":true,"config":{}}]`,
|
||||
},
|
||||
{
|
||||
"Daily Standup Bot",
|
||||
"Generates daily standup summaries from recent activity",
|
||||
"cloud", "working",
|
||||
"# Daily Standup Bot\n\nGenerate a daily standup summary based on workspace activity.\n\n## Tasks\n1. Collect all issue status changes from the last 24 hours\n2. Summarize what each team member worked on\n3. Identify blocked items\n4. Post the summary to the team channel",
|
||||
`[{"id":"tool-4","name":"Slack","description":"Send messages to Slack channels","auth_type":"oauth","connected":true,"config":{"channel":"#standup"}}]`,
|
||||
`[{"id":"trigger-3","type":"scheduled","enabled":true,"config":{"cron":"0 9 * * 1-5","timezone":"Asia/Shanghai"}}]`,
|
||||
},
|
||||
{
|
||||
"Local Dev Agent",
|
||||
"A local development agent running on your machine",
|
||||
"local", "offline",
|
||||
"",
|
||||
`[]`,
|
||||
`[{"id":"trigger-4","type":"on_assign","enabled":true,"config":{}}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, a := range agents {
|
||||
|
|
@ -82,10 +114,10 @@ func main() {
|
|||
continue
|
||||
}
|
||||
err = pool.QueryRow(ctx, `
|
||||
INSERT INTO agent (workspace_id, name, runtime_mode, status, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO agent (workspace_id, name, description, runtime_mode, status, owner_id, skills, tools, triggers)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb)
|
||||
RETURNING id
|
||||
`, workspaceID, a.name, a.runtimeMode, a.status, userID).Scan(&agentID)
|
||||
`, workspaceID, a.name, a.description, a.runtimeMode, a.status, userID, a.skills, a.tools, a.triggers).Scan(&agentID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create agent %s: %v", a.name, err)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -72,6 +72,22 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub) chi.Router {
|
|||
// Auth (public)
|
||||
r.Post("/auth/login", h.Login)
|
||||
|
||||
// Daemon API routes (no user auth; daemon auth deferred to later)
|
||||
r.Route("/api/daemon", func(r chi.Router) {
|
||||
r.Post("/register", h.DaemonRegister)
|
||||
r.Post("/heartbeat", h.DaemonHeartbeat)
|
||||
|
||||
// Task claiming (daemon polls for work)
|
||||
r.Post("/agents/{agentId}/tasks/claim", h.ClaimTask)
|
||||
r.Get("/agents/{agentId}/tasks/pending", h.ListPendingTasks)
|
||||
|
||||
// Task lifecycle (daemon reports status)
|
||||
r.Post("/tasks/{taskId}/start", h.StartTask)
|
||||
r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress)
|
||||
r.Post("/tasks/{taskId}/complete", h.CompleteTask)
|
||||
r.Post("/tasks/{taskId}/fail", h.FailTask)
|
||||
})
|
||||
|
||||
// Protected API routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Auth)
|
||||
|
|
@ -100,6 +116,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub) chi.Router {
|
|||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetAgent)
|
||||
r.Put("/", h.UpdateAgent)
|
||||
r.Delete("/", h.DeleteAgent)
|
||||
r.Get("/tasks", h.ListAgentTasks)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,18 +10,22 @@ import (
|
|||
)
|
||||
|
||||
type AgentResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Skills string `json:"skills"`
|
||||
Tools any `json:"tools"`
|
||||
Triggers any `json:"triggers"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func agentToResponse(a db.Agent) AgentResponse {
|
||||
|
|
@ -32,10 +36,28 @@ func agentToResponse(a db.Agent) AgentResponse {
|
|||
if rc == nil {
|
||||
rc = map[string]any{}
|
||||
}
|
||||
|
||||
var tools any
|
||||
if a.Tools != nil {
|
||||
json.Unmarshal(a.Tools, &tools)
|
||||
}
|
||||
if tools == nil {
|
||||
tools = []any{}
|
||||
}
|
||||
|
||||
var triggers any
|
||||
if a.Triggers != nil {
|
||||
json.Unmarshal(a.Triggers, &triggers)
|
||||
}
|
||||
if triggers == nil {
|
||||
triggers = []any{}
|
||||
}
|
||||
|
||||
return AgentResponse{
|
||||
ID: uuidToString(a.ID),
|
||||
WorkspaceID: uuidToString(a.WorkspaceID),
|
||||
Name: a.Name,
|
||||
Description: a.Description,
|
||||
AvatarURL: textToPtr(a.AvatarUrl),
|
||||
RuntimeMode: a.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
|
|
@ -43,11 +65,54 @@ func agentToResponse(a db.Agent) AgentResponse {
|
|||
Status: a.Status,
|
||||
MaxConcurrentTasks: a.MaxConcurrentTasks,
|
||||
OwnerID: uuidToPtr(a.OwnerID),
|
||||
Skills: a.Skills,
|
||||
Tools: tools,
|
||||
Triggers: triggers,
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
UpdatedAt: timestampToString(a.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
type AgentTaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt *string `json:"dispatched_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Result any `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
Context any `json:"context,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
var result any
|
||||
if t.Result != nil {
|
||||
json.Unmarshal(t.Result, &result)
|
||||
}
|
||||
var ctx any
|
||||
if t.Context != nil {
|
||||
json.Unmarshal(t.Context, &ctx)
|
||||
}
|
||||
return AgentTaskResponse{
|
||||
ID: uuidToString(t.ID),
|
||||
AgentID: uuidToString(t.AgentID),
|
||||
IssueID: uuidToString(t.IssueID),
|
||||
Status: t.Status,
|
||||
Priority: t.Priority,
|
||||
DispatchedAt: timestampToPtr(t.DispatchedAt),
|
||||
StartedAt: timestampToPtr(t.StartedAt),
|
||||
CompletedAt: timestampToPtr(t.CompletedAt),
|
||||
Result: result,
|
||||
Error: textToPtr(t.Error),
|
||||
Context: ctx,
|
||||
CreatedAt: timestampToString(t.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
|
|
@ -79,11 +144,15 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
type CreateAgentRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
Skills string `json:"skills"`
|
||||
Tools any `json:"tools"`
|
||||
Triggers any `json:"triggers"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -122,15 +191,29 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
rc = []byte("{}")
|
||||
}
|
||||
|
||||
tools, _ := json.Marshal(req.Tools)
|
||||
if req.Tools == nil {
|
||||
tools = []byte("[]")
|
||||
}
|
||||
|
||||
triggers, _ := json.Marshal(req.Triggers)
|
||||
if req.Triggers == nil {
|
||||
triggers = []byte("[]")
|
||||
}
|
||||
|
||||
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
AvatarUrl: ptrToText(req.AvatarURL),
|
||||
RuntimeMode: req.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
Visibility: req.Visibility,
|
||||
MaxConcurrentTasks: req.MaxConcurrentTasks,
|
||||
OwnerID: parseUUID(ownerID),
|
||||
Skills: req.Skills,
|
||||
Tools: tools,
|
||||
Triggers: triggers,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create agent: "+err.Error())
|
||||
|
|
@ -142,11 +225,15 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
type UpdateAgentRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility *string `json:"visibility"`
|
||||
Status *string `json:"status"`
|
||||
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
|
||||
Skills *string `json:"skills"`
|
||||
Tools any `json:"tools"`
|
||||
Triggers any `json:"triggers"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -171,6 +258,9 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
if req.Name != nil {
|
||||
params.Name = pgtype.Text{String: *req.Name, Valid: true}
|
||||
}
|
||||
if req.Description != nil {
|
||||
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
params.AvatarUrl = pgtype.Text{String: *req.AvatarURL, Valid: true}
|
||||
}
|
||||
|
|
@ -187,6 +277,17 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
if req.MaxConcurrentTasks != nil {
|
||||
params.MaxConcurrentTasks = pgtype.Int4{Int32: *req.MaxConcurrentTasks, Valid: true}
|
||||
}
|
||||
if req.Skills != nil {
|
||||
params.Skills = pgtype.Text{String: *req.Skills, Valid: true}
|
||||
}
|
||||
if req.Tools != nil {
|
||||
tools, _ := json.Marshal(req.Tools)
|
||||
params.Tools = tools
|
||||
}
|
||||
if req.Triggers != nil {
|
||||
triggers, _ := json.Marshal(req.Triggers)
|
||||
params.Triggers = triggers
|
||||
}
|
||||
|
||||
agent, err := h.Queries.UpdateAgent(r.Context(), params)
|
||||
if err != nil {
|
||||
|
|
@ -198,3 +299,29 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
h.broadcast("agent:status", map[string]any{"agent": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
err := h.Queries.DeleteAgent(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete agent")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
tasks, err := h.Queries.ListAgentTasks(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agent tasks")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentTaskResponse, len(tasks))
|
||||
for i, t := range tasks {
|
||||
resp[i] = taskToResponse(t)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
|
|
|||
202
server/internal/handler/daemon.go
Normal file
202
server/internal/handler/daemon.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daemon Registration & Heartbeat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DaemonRegisterRequest struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Runtimes []struct {
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
} `json:"runtimes"`
|
||||
}
|
||||
|
||||
func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req DaemonRegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.DaemonID == "" || req.AgentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "daemon_id and agent_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
runtimeInfo, _ := json.Marshal(req.Runtimes)
|
||||
|
||||
conn, err := h.Queries.UpsertDaemonConnection(r.Context(), db.UpsertDaemonConnectionParams{
|
||||
AgentID: parseUUID(req.AgentID),
|
||||
DaemonID: req.DaemonID,
|
||||
RuntimeInfo: runtimeInfo,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to register daemon: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Reconcile agent status (set to idle if no running tasks)
|
||||
h.TaskService.ReconcileAgentStatus(r.Context(), parseUUID(req.AgentID))
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"connection_id": uuidToString(conn.ID),
|
||||
"status": conn.Status,
|
||||
})
|
||||
}
|
||||
|
||||
type DaemonHeartbeatRequest struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
CurrentTasks int `json:"current_tasks"`
|
||||
}
|
||||
|
||||
func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
var req DaemonHeartbeatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.Queries.UpdateDaemonHeartbeat(r.Context(), db.UpdateDaemonHeartbeatParams{
|
||||
DaemonID: req.DaemonID,
|
||||
AgentID: parseUUID(req.AgentID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "heartbeat failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task Lifecycle (called by daemon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ClaimTask atomically claims the next queued task for an agent.
|
||||
func (h *Handler) ClaimTask(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentId")
|
||||
|
||||
task, err := h.TaskService.ClaimTask(r.Context(), parseUUID(agentID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to claim task: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if task == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": nil})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": taskToResponse(*task)})
|
||||
}
|
||||
|
||||
// ListPendingTasks returns queued/dispatched tasks for an agent.
|
||||
func (h *Handler) ListPendingTasks(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentId")
|
||||
|
||||
tasks, err := h.Queries.ListPendingTasks(r.Context(), parseUUID(agentID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list pending tasks")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentTaskResponse, len(tasks))
|
||||
for i, t := range tasks {
|
||||
resp[i] = taskToResponse(t)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// StartTask marks a dispatched task as running.
|
||||
func (h *Handler) StartTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
|
||||
task, err := h.TaskService.StartTask(r.Context(), parseUUID(taskID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
}
|
||||
|
||||
// ReportTaskProgress broadcasts a progress update.
|
||||
type TaskProgressRequest struct {
|
||||
Summary string `json:"summary"`
|
||||
Step int `json:"step"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func (h *Handler) ReportTaskProgress(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
|
||||
var req TaskProgressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
h.TaskService.ReportProgress(taskID, req.Summary, req.Step, req.Total)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// CompleteTask marks a running task as completed.
|
||||
type TaskCompleteRequest struct {
|
||||
PRURL string `json:"pr_url"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
func (h *Handler) CompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
|
||||
var req TaskCompleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(req)
|
||||
task, err := h.TaskService.CompleteTask(r.Context(), parseUUID(taskID), result)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
}
|
||||
|
||||
// FailTask marks a running task as failed.
|
||||
type TaskFailRequest struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (h *Handler) FailTask(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskId")
|
||||
|
||||
var req TaskFailRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.TaskService.FailTask(r.Context(), parseUUID(taskID), req.Error)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, taskToResponse(*task))
|
||||
}
|
||||
|
|
@ -2,18 +2,18 @@ package handler
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
)
|
||||
|
||||
type txStarter interface {
|
||||
|
|
@ -21,13 +21,19 @@ type txStarter interface {
|
|||
}
|
||||
|
||||
type Handler struct {
|
||||
Queries *db.Queries
|
||||
TxStarter txStarter
|
||||
Hub *realtime.Hub
|
||||
Queries *db.Queries
|
||||
TxStarter txStarter
|
||||
Hub *realtime.Hub
|
||||
TaskService *service.TaskService
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub) *Handler {
|
||||
return &Handler{Queries: queries, TxStarter: txStarter, Hub: hub}
|
||||
return &Handler{
|
||||
Queries: queries,
|
||||
TxStarter: txStarter,
|
||||
Hub: hub,
|
||||
TaskService: service.NewTaskService(queries, hub),
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
|
@ -40,73 +46,15 @@ func writeError(w http.ResponseWriter, status int, msg string) {
|
|||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func parseUUID(s string) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
_ = u.Scan(s)
|
||||
return u
|
||||
}
|
||||
|
||||
func uuidToString(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return ""
|
||||
}
|
||||
b := u.Bytes
|
||||
dst := make([]byte, 36)
|
||||
hex.Encode(dst[0:8], b[0:4])
|
||||
dst[8] = '-'
|
||||
hex.Encode(dst[9:13], b[4:6])
|
||||
dst[13] = '-'
|
||||
hex.Encode(dst[14:18], b[6:8])
|
||||
dst[18] = '-'
|
||||
hex.Encode(dst[19:23], b[8:10])
|
||||
dst[23] = '-'
|
||||
hex.Encode(dst[24:36], b[10:16])
|
||||
return string(dst)
|
||||
}
|
||||
|
||||
func textToPtr(t pgtype.Text) *string {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
return &t.String
|
||||
}
|
||||
|
||||
func ptrToText(s *string) pgtype.Text {
|
||||
if s == nil {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: *s, Valid: true}
|
||||
}
|
||||
|
||||
func strToText(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
func timestampToString(t pgtype.Timestamptz) string {
|
||||
if !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return t.Time.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func timestampToPtr(t pgtype.Timestamptz) *string {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
s := t.Time.Format(time.RFC3339)
|
||||
return &s
|
||||
}
|
||||
|
||||
func uuidToPtr(u pgtype.UUID) *string {
|
||||
if !u.Valid {
|
||||
return nil
|
||||
}
|
||||
s := uuidToString(u)
|
||||
return &s
|
||||
}
|
||||
// Thin wrappers around util functions (preserve existing handler code unchanged).
|
||||
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
|
||||
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
|
||||
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
|
||||
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
|
||||
func strToText(s string) pgtype.Text { return util.StrToText(s) }
|
||||
func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToString(t) }
|
||||
func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) }
|
||||
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
|
||||
|
||||
// broadcast sends a WebSocket event to all connected clients.
|
||||
func (h *Handler) broadcast(eventType string, payload any) {
|
||||
|
|
|
|||
|
|
@ -238,6 +238,11 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
if err == nil {
|
||||
h.broadcast("inbox:new", map[string]any{"item": inboxToResponse(inboxItem)})
|
||||
}
|
||||
|
||||
// If assigned to an agent, enqueue a task with context
|
||||
if issue.AssigneeType.String == "agent" {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
|
|
@ -300,6 +305,33 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
resp := issueToResponse(issue)
|
||||
h.broadcast("issue:updated", map[string]any{"issue": resp})
|
||||
|
||||
// If assignee changed, handle agent task queue
|
||||
if req.AssigneeType != nil || req.AssigneeID != nil {
|
||||
// Cancel any existing agent tasks for this issue
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
|
||||
// If newly assigned to an agent, enqueue a task with context
|
||||
if issue.AssigneeType.Valid && issue.AssigneeType.String == "agent" && issue.AssigneeID.Valid {
|
||||
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
|
||||
}
|
||||
|
||||
// Create inbox notification for new assignee
|
||||
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
|
||||
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
RecipientType: issue.AssigneeType.String,
|
||||
RecipientID: issue.AssigneeID,
|
||||
Type: "issue_assigned",
|
||||
Severity: "action_required",
|
||||
IssueID: issue.ID,
|
||||
Title: "Assigned to you: " + issue.Title,
|
||||
})
|
||||
if err == nil {
|
||||
h.broadcast("inbox:new", map[string]any{"item": inboxToResponse(inboxItem)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If status changed, create a notification
|
||||
if req.Status != nil {
|
||||
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
|
||||
|
|
|
|||
288
server/internal/service/task.go
Normal file
288
server/internal/service/task.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
Queries *db.Queries
|
||||
Hub *realtime.Hub
|
||||
}
|
||||
|
||||
func NewTaskService(q *db.Queries, hub *realtime.Hub) *TaskService {
|
||||
return &TaskService{Queries: q, Hub: hub}
|
||||
}
|
||||
|
||||
// EnqueueTaskForIssue creates a task with a context snapshot of the issue.
|
||||
func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (db.AgentTaskQueue, error) {
|
||||
if !issue.AssigneeID.Valid {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("issue has no assignee")
|
||||
}
|
||||
|
||||
snapshot := buildContextSnapshot(issue)
|
||||
contextJSON, _ := json.Marshal(snapshot)
|
||||
|
||||
task, err := s.Queries.CreateAgentTaskWithContext(ctx, db.CreateAgentTaskWithContextParams{
|
||||
AgentID: issue.AssigneeID,
|
||||
IssueID: issue.ID,
|
||||
Priority: priorityToInt(issue.Priority),
|
||||
Context: contextJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("create task: %w", err)
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// CancelTasksForIssue cancels all active tasks for an issue.
|
||||
func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UUID) error {
|
||||
return s.Queries.CancelAgentTasksByIssue(ctx, issueID)
|
||||
}
|
||||
|
||||
// ClaimTask atomically claims the next queued task for an agent,
|
||||
// respecting max_concurrent_tasks.
|
||||
func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.AgentTaskQueue, error) {
|
||||
agent, err := s.Queries.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent not found: %w", err)
|
||||
}
|
||||
|
||||
running, err := s.Queries.CountRunningTasks(ctx, agentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count running tasks: %w", err)
|
||||
}
|
||||
if running >= int64(agent.MaxConcurrentTasks) {
|
||||
return nil, nil // No capacity
|
||||
}
|
||||
|
||||
task, err := s.Queries.ClaimAgentTask(ctx, agentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil // No tasks available
|
||||
}
|
||||
return nil, fmt.Errorf("claim task: %w", err)
|
||||
}
|
||||
|
||||
// Update agent status to working
|
||||
s.updateAgentStatus(ctx, agentID, "working")
|
||||
|
||||
// Broadcast task:dispatch
|
||||
s.broadcastTaskDispatch(task)
|
||||
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// StartTask transitions a dispatched task to running and syncs issue status.
|
||||
func (s *TaskService) StartTask(ctx context.Context, taskID pgtype.UUID) (*db.AgentTaskQueue, error) {
|
||||
task, err := s.Queries.StartAgentTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start task: %w", err)
|
||||
}
|
||||
|
||||
// Sync issue → in_progress
|
||||
s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
ID: task.IssueID,
|
||||
Status: "in_progress",
|
||||
})
|
||||
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// CompleteTask marks a task as completed and syncs issue/agent status.
|
||||
func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, result []byte) (*db.AgentTaskQueue, error) {
|
||||
task, err := s.Queries.CompleteAgentTask(ctx, db.CompleteAgentTaskParams{
|
||||
ID: taskID,
|
||||
Result: result,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("complete task: %w", err)
|
||||
}
|
||||
|
||||
// Sync issue → in_review
|
||||
s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
ID: task.IssueID,
|
||||
Status: "in_review",
|
||||
})
|
||||
|
||||
// Reconcile agent status
|
||||
s.ReconcileAgentStatus(ctx, task.AgentID)
|
||||
|
||||
// Broadcast
|
||||
s.broadcastTaskEvent(protocol.EventTaskCompleted, task)
|
||||
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// FailTask marks a task as failed and syncs issue/agent status.
|
||||
func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg string) (*db.AgentTaskQueue, error) {
|
||||
task, err := s.Queries.FailAgentTask(ctx, db.FailAgentTaskParams{
|
||||
ID: taskID,
|
||||
Error: pgtype.Text{String: errMsg, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail task: %w", err)
|
||||
}
|
||||
|
||||
// Sync issue → blocked
|
||||
s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
|
||||
ID: task.IssueID,
|
||||
Status: "blocked",
|
||||
})
|
||||
|
||||
// Reconcile agent status
|
||||
s.ReconcileAgentStatus(ctx, task.AgentID)
|
||||
|
||||
// Broadcast
|
||||
s.broadcastTaskEvent(protocol.EventTaskFailed, task)
|
||||
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// ReportProgress broadcasts a progress update via WebSocket.
|
||||
func (s *TaskService) ReportProgress(taskID string, summary string, step, total int) {
|
||||
s.broadcast(protocol.EventTaskProgress, protocol.TaskProgressPayload{
|
||||
TaskID: taskID,
|
||||
Summary: summary,
|
||||
Step: step,
|
||||
Total: total,
|
||||
})
|
||||
}
|
||||
|
||||
// ReconcileAgentStatus checks running task count and sets agent status accordingly.
|
||||
func (s *TaskService) ReconcileAgentStatus(ctx context.Context, agentID pgtype.UUID) {
|
||||
running, err := s.Queries.CountRunningTasks(ctx, agentID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
newStatus := "idle"
|
||||
if running > 0 {
|
||||
newStatus = "working"
|
||||
}
|
||||
s.updateAgentStatus(ctx, agentID, newStatus)
|
||||
}
|
||||
|
||||
func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID, status string) {
|
||||
agent, err := s.Queries.UpdateAgentStatus(ctx, db.UpdateAgentStatusParams{
|
||||
ID: agentID,
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.broadcast(protocol.EventAgentStatus, map[string]any{"agent": agentToMap(agent)})
|
||||
}
|
||||
|
||||
func buildContextSnapshot(issue db.Issue) protocol.TaskDispatchPayload {
|
||||
var ac []string
|
||||
if issue.AcceptanceCriteria != nil {
|
||||
json.Unmarshal(issue.AcceptanceCriteria, &ac)
|
||||
}
|
||||
var cr []string
|
||||
if issue.ContextRefs != nil {
|
||||
json.Unmarshal(issue.ContextRefs, &cr)
|
||||
}
|
||||
var repo *protocol.RepoRef
|
||||
if issue.Repository != nil {
|
||||
repo = &protocol.RepoRef{}
|
||||
json.Unmarshal(issue.Repository, repo)
|
||||
}
|
||||
|
||||
return protocol.TaskDispatchPayload{
|
||||
IssueID: util.UUIDToString(issue.ID),
|
||||
Title: issue.Title,
|
||||
Description: issue.Description.String,
|
||||
AcceptanceCriteria: ac,
|
||||
ContextRefs: cr,
|
||||
Repository: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func priorityToInt(p string) int32 {
|
||||
switch p {
|
||||
case "urgent":
|
||||
return 4
|
||||
case "high":
|
||||
return 3
|
||||
case "medium":
|
||||
return 2
|
||||
case "low":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TaskService) broadcastTaskDispatch(task db.AgentTaskQueue) {
|
||||
var payload protocol.TaskDispatchPayload
|
||||
if task.Context != nil {
|
||||
json.Unmarshal(task.Context, &payload)
|
||||
}
|
||||
payload.TaskID = util.UUIDToString(task.ID)
|
||||
s.broadcast(protocol.EventTaskDispatch, payload)
|
||||
}
|
||||
|
||||
func (s *TaskService) broadcastTaskEvent(eventType string, task db.AgentTaskQueue) {
|
||||
s.broadcast(eventType, map[string]any{
|
||||
"task_id": util.UUIDToString(task.ID),
|
||||
"agent_id": util.UUIDToString(task.AgentID),
|
||||
"issue_id": util.UUIDToString(task.IssueID),
|
||||
"status": task.Status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TaskService) broadcast(eventType string, payload any) {
|
||||
msg := map[string]any{
|
||||
"type": eventType,
|
||||
"payload": payload,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.Hub.Broadcast(data)
|
||||
}
|
||||
|
||||
// agentToMap builds a simple map for broadcasting agent status updates.
|
||||
func agentToMap(a db.Agent) map[string]any {
|
||||
var rc any
|
||||
if a.RuntimeConfig != nil {
|
||||
json.Unmarshal(a.RuntimeConfig, &rc)
|
||||
}
|
||||
var tools any
|
||||
if a.Tools != nil {
|
||||
json.Unmarshal(a.Tools, &tools)
|
||||
}
|
||||
var triggers any
|
||||
if a.Triggers != nil {
|
||||
json.Unmarshal(a.Triggers, &triggers)
|
||||
}
|
||||
return map[string]any{
|
||||
"id": util.UUIDToString(a.ID),
|
||||
"workspace_id": util.UUIDToString(a.WorkspaceID),
|
||||
"name": a.Name,
|
||||
"description": a.Description,
|
||||
"avatar_url": util.TextToPtr(a.AvatarUrl),
|
||||
"runtime_mode": a.RuntimeMode,
|
||||
"runtime_config": rc,
|
||||
"visibility": a.Visibility,
|
||||
"status": a.Status,
|
||||
"max_concurrent_tasks": a.MaxConcurrentTasks,
|
||||
"owner_id": util.UUIDToPtr(a.OwnerID),
|
||||
"skills": a.Skills,
|
||||
"tools": tools,
|
||||
"triggers": triggers,
|
||||
"created_at": util.TimestampToString(a.CreatedAt),
|
||||
"updated_at": util.TimestampToString(a.UpdatedAt),
|
||||
}
|
||||
}
|
||||
76
server/internal/util/pgx.go
Normal file
76
server/internal/util/pgx.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func ParseUUID(s string) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
_ = u.Scan(s)
|
||||
return u
|
||||
}
|
||||
|
||||
func UUIDToString(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return ""
|
||||
}
|
||||
b := u.Bytes
|
||||
dst := make([]byte, 36)
|
||||
hex.Encode(dst[0:8], b[0:4])
|
||||
dst[8] = '-'
|
||||
hex.Encode(dst[9:13], b[4:6])
|
||||
dst[13] = '-'
|
||||
hex.Encode(dst[14:18], b[6:8])
|
||||
dst[18] = '-'
|
||||
hex.Encode(dst[19:23], b[8:10])
|
||||
dst[23] = '-'
|
||||
hex.Encode(dst[24:36], b[10:16])
|
||||
return string(dst)
|
||||
}
|
||||
|
||||
func TextToPtr(t pgtype.Text) *string {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
return &t.String
|
||||
}
|
||||
|
||||
func PtrToText(s *string) pgtype.Text {
|
||||
if s == nil {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: *s, Valid: true}
|
||||
}
|
||||
|
||||
func StrToText(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
func TimestampToString(t pgtype.Timestamptz) string {
|
||||
if !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return t.Time.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func TimestampToPtr(t pgtype.Timestamptz) *string {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
s := t.Time.Format(time.RFC3339)
|
||||
return &s
|
||||
}
|
||||
|
||||
func UUIDToPtr(u pgtype.UUID) *string {
|
||||
if !u.Valid {
|
||||
return nil
|
||||
}
|
||||
s := UUIDToString(u)
|
||||
return &s
|
||||
}
|
||||
5
server/migrations/002_agent_config.down.sql
Normal file
5
server/migrations/002_agent_config.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE agent
|
||||
DROP COLUMN IF EXISTS description,
|
||||
DROP COLUMN IF EXISTS skills,
|
||||
DROP COLUMN IF EXISTS tools,
|
||||
DROP COLUMN IF EXISTS triggers;
|
||||
6
server/migrations/002_agent_config.up.sql
Normal file
6
server/migrations/002_agent_config.up.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Add agent configuration columns: skills, tools, triggers
|
||||
ALTER TABLE agent
|
||||
ADD COLUMN description TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN skills TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN tools JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN triggers JSONB NOT NULL DEFAULT '[]';
|
||||
3
server/migrations/003_task_context.down.sql
Normal file
3
server/migrations/003_task_context.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE daemon_connection DROP CONSTRAINT IF EXISTS uq_daemon_agent;
|
||||
DROP INDEX IF EXISTS idx_agent_task_queue_pending;
|
||||
ALTER TABLE agent_task_queue DROP COLUMN IF EXISTS context;
|
||||
12
server/migrations/003_task_context.up.sql
Normal file
12
server/migrations/003_task_context.up.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- Add context snapshot to agent tasks so daemons have everything needed to execute
|
||||
ALTER TABLE agent_task_queue
|
||||
ADD COLUMN context JSONB;
|
||||
|
||||
-- Partial index for efficient daemon polling of pending tasks
|
||||
CREATE INDEX idx_agent_task_queue_pending
|
||||
ON agent_task_queue(agent_id, priority DESC, created_at ASC)
|
||||
WHERE status IN ('queued', 'dispatched');
|
||||
|
||||
-- Unique constraint for daemon connection upsert
|
||||
ALTER TABLE daemon_connection
|
||||
ADD CONSTRAINT uq_daemon_agent UNIQUE (agent_id, daemon_id);
|
||||
|
|
@ -11,35 +11,132 @@ import (
|
|||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const cancelAgentTasksByIssue = `-- name: CancelAgentTasksByIssue :exec
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled'
|
||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running')
|
||||
`
|
||||
|
||||
func (q *Queries) CancelAgentTasksByIssue(ctx context.Context, issueID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, cancelAgentTasksByIssue, issueID)
|
||||
return err
|
||||
}
|
||||
|
||||
const claimAgentTask = `-- name: ClaimAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'dispatched', dispatched_at = now()
|
||||
WHERE id = (
|
||||
SELECT atq.id FROM agent_task_queue atq
|
||||
WHERE atq.agent_id = $1 AND atq.status = 'queued'
|
||||
ORDER BY atq.priority DESC, atq.created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context
|
||||
`
|
||||
|
||||
func (q *Queries) ClaimAgentTask(ctx context.Context, agentID pgtype.UUID) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, claimAgentTask, agentID)
|
||||
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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const completeAgentTask = `-- name: CompleteAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'completed', completed_at = now(), result = $2
|
||||
WHERE id = $1 AND status = 'running'
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context
|
||||
`
|
||||
|
||||
type CompleteAgentTaskParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Result []byte `json:"result"`
|
||||
}
|
||||
|
||||
func (q *Queries) CompleteAgentTask(ctx context.Context, arg CompleteAgentTaskParams) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, completeAgentTask, arg.ID, arg.Result)
|
||||
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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const countRunningTasks = `-- name: CountRunningTasks :one
|
||||
SELECT count(*) FROM agent_task_queue
|
||||
WHERE agent_id = $1 AND status IN ('dispatched', 'running')
|
||||
`
|
||||
|
||||
func (q *Queries) CountRunningTasks(ctx context.Context, agentID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countRunningTasks, agentID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createAgent = `-- name: CreateAgent :one
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, avatar_url, runtime_mode,
|
||||
runtime_config, visibility, max_concurrent_tasks, owner_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at
|
||||
workspace_id, name, description, avatar_url, runtime_mode,
|
||||
runtime_config, visibility, max_concurrent_tasks, owner_id,
|
||||
skills, tools, triggers
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers
|
||||
`
|
||||
|
||||
type CreateAgentParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Skills string `json:"skills"`
|
||||
Tools []byte `json:"tools"`
|
||||
Triggers []byte `json:"triggers"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, createAgent,
|
||||
arg.WorkspaceID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.AvatarUrl,
|
||||
arg.RuntimeMode,
|
||||
arg.RuntimeConfig,
|
||||
arg.Visibility,
|
||||
arg.MaxConcurrentTasks,
|
||||
arg.OwnerID,
|
||||
arg.Skills,
|
||||
arg.Tools,
|
||||
arg.Triggers,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
|
|
@ -55,6 +152,80 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
|||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createAgentTask = `-- name: CreateAgentTask :one
|
||||
INSERT INTO agent_task_queue (agent_id, issue_id, status, priority)
|
||||
VALUES ($1, $2, 'queued', $3)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context
|
||||
`
|
||||
|
||||
type CreateAgentTaskParams struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, createAgentTask, arg.AgentID, arg.IssueID, arg.Priority)
|
||||
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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createAgentTaskWithContext = `-- name: CreateAgentTaskWithContext :one
|
||||
INSERT INTO agent_task_queue (agent_id, issue_id, status, priority, context)
|
||||
VALUES ($1, $2, 'queued', $3, $4)
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context
|
||||
`
|
||||
|
||||
type CreateAgentTaskWithContextParams struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
Context []byte `json:"context"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgentTaskWithContext(ctx context.Context, arg CreateAgentTaskWithContextParams) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, createAgentTaskWithContext,
|
||||
arg.AgentID,
|
||||
arg.IssueID,
|
||||
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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -68,8 +239,51 @@ func (q *Queries) DeleteAgent(ctx context.Context, id pgtype.UUID) error {
|
|||
return err
|
||||
}
|
||||
|
||||
const disconnectDaemon = `-- name: DisconnectDaemon :exec
|
||||
UPDATE daemon_connection
|
||||
SET status = 'disconnected', updated_at = now()
|
||||
WHERE daemon_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DisconnectDaemon(ctx context.Context, daemonID string) error {
|
||||
_, err := q.db.Exec(ctx, disconnectDaemon, daemonID)
|
||||
return err
|
||||
}
|
||||
|
||||
const failAgentTask = `-- name: FailAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'failed', completed_at = now(), error = $2
|
||||
WHERE id = $1 AND status = 'running'
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context
|
||||
`
|
||||
|
||||
type FailAgentTaskParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Error pgtype.Text `json:"error"`
|
||||
}
|
||||
|
||||
func (q *Queries) FailAgentTask(ctx context.Context, arg FailAgentTaskParams) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, failAgentTask, arg.ID, arg.Error)
|
||||
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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgent = `-- name: GetAgent :one
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers FROM agent
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
|
@ -89,12 +303,80 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
|||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAgentTask = `-- name: GetAgentTask :one
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context FROM agent_task_queue
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetAgentTask(ctx context.Context, id pgtype.UUID) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, getAgentTask, id)
|
||||
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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAgentTasks = `-- name: ListAgentTasks :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context FROM agent_task_queue
|
||||
WHERE agent_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]AgentTaskQueue, error) {
|
||||
rows, err := q.db.Query(ctx, listAgentTasks, agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AgentTaskQueue{}
|
||||
for rows.Next() {
|
||||
var i AgentTaskQueue
|
||||
if err := rows.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,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAgents = `-- name: ListAgents :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at FROM agent
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
|
@ -121,6 +403,10 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
|||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -132,38 +418,116 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const listPendingTasks = `-- name: ListPendingTasks :many
|
||||
SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context FROM agent_task_queue
|
||||
WHERE agent_id = $1 AND status IN ('queued', 'dispatched')
|
||||
ORDER BY priority DESC, created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListPendingTasks(ctx context.Context, agentID pgtype.UUID) ([]AgentTaskQueue, error) {
|
||||
rows, err := q.db.Query(ctx, listPendingTasks, agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AgentTaskQueue{}
|
||||
for rows.Next() {
|
||||
var i AgentTaskQueue
|
||||
if err := rows.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,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const startAgentTask = `-- name: StartAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'running', started_at = now()
|
||||
WHERE id = $1 AND status = 'dispatched'
|
||||
RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context
|
||||
`
|
||||
|
||||
func (q *Queries) StartAgentTask(ctx context.Context, id pgtype.UUID) (AgentTaskQueue, error) {
|
||||
row := q.db.QueryRow(ctx, startAgentTask, id)
|
||||
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,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateAgent = `-- name: UpdateAgent :one
|
||||
UPDATE agent SET
|
||||
name = COALESCE($2, name),
|
||||
avatar_url = COALESCE($3, avatar_url),
|
||||
runtime_config = COALESCE($4, runtime_config),
|
||||
visibility = COALESCE($5, visibility),
|
||||
status = COALESCE($6, status),
|
||||
max_concurrent_tasks = COALESCE($7, max_concurrent_tasks),
|
||||
description = COALESCE($3, description),
|
||||
avatar_url = COALESCE($4, avatar_url),
|
||||
runtime_config = COALESCE($5, runtime_config),
|
||||
visibility = COALESCE($6, visibility),
|
||||
status = COALESCE($7, status),
|
||||
max_concurrent_tasks = COALESCE($8, max_concurrent_tasks),
|
||||
skills = COALESCE($9, skills),
|
||||
tools = COALESCE($10, tools),
|
||||
triggers = COALESCE($11, triggers),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers
|
||||
`
|
||||
|
||||
type UpdateAgentParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
Visibility pgtype.Text `json:"visibility"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
|
||||
Skills pgtype.Text `json:"skills"`
|
||||
Tools []byte `json:"tools"`
|
||||
Triggers []byte `json:"triggers"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, updateAgent,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.AvatarUrl,
|
||||
arg.RuntimeConfig,
|
||||
arg.Visibility,
|
||||
arg.Status,
|
||||
arg.MaxConcurrentTasks,
|
||||
arg.Skills,
|
||||
arg.Tools,
|
||||
arg.Triggers,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
|
|
@ -179,6 +543,91 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
|||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateAgentStatus = `-- name: UpdateAgentStatus :one
|
||||
UPDATE agent SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, skills, tools, triggers
|
||||
`
|
||||
|
||||
type UpdateAgentStatusParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusParams) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, updateAgentStatus, arg.ID, arg.Status)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Description,
|
||||
&i.Skills,
|
||||
&i.Tools,
|
||||
&i.Triggers,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateDaemonHeartbeat = `-- name: UpdateDaemonHeartbeat :exec
|
||||
UPDATE daemon_connection
|
||||
SET last_heartbeat_at = now(), updated_at = now()
|
||||
WHERE daemon_id = $1 AND agent_id = $2
|
||||
`
|
||||
|
||||
type UpdateDaemonHeartbeatParams struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateDaemonHeartbeat(ctx context.Context, arg UpdateDaemonHeartbeatParams) error {
|
||||
_, err := q.db.Exec(ctx, updateDaemonHeartbeat, arg.DaemonID, arg.AgentID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertDaemonConnection = `-- name: UpsertDaemonConnection :one
|
||||
INSERT INTO daemon_connection (agent_id, daemon_id, status, last_heartbeat_at, runtime_info)
|
||||
VALUES ($1, $2, 'connected', now(), $3)
|
||||
ON CONFLICT ON CONSTRAINT uq_daemon_agent
|
||||
DO UPDATE SET status = 'connected', last_heartbeat_at = now(), runtime_info = $3, updated_at = now()
|
||||
RETURNING id, agent_id, daemon_id, status, last_heartbeat_at, runtime_info, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertDaemonConnectionParams struct {
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
RuntimeInfo []byte `json:"runtime_info"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertDaemonConnection(ctx context.Context, arg UpsertDaemonConnectionParams) (DaemonConnection, error) {
|
||||
row := q.db.QueryRow(ctx, upsertDaemonConnection, arg.AgentID, arg.DaemonID, arg.RuntimeInfo)
|
||||
var i DaemonConnection
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AgentID,
|
||||
&i.DaemonID,
|
||||
&i.Status,
|
||||
&i.LastHeartbeatAt,
|
||||
&i.RuntimeInfo,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,3 +231,42 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
|
|||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateIssueStatus = `-- name: UpdateIssueStatus :one
|
||||
UPDATE issue SET
|
||||
status = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateIssueStatusParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusParams) (Issue, error) {
|
||||
row := q.db.QueryRow(ctx, updateIssueStatus, arg.ID, arg.Status)
|
||||
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.Repository,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ type Agent struct {
|
|||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
Description string `json:"description"`
|
||||
Skills string `json:"skills"`
|
||||
Tools []byte `json:"tools"`
|
||||
Triggers []byte `json:"triggers"`
|
||||
}
|
||||
|
||||
type AgentTaskQueue struct {
|
||||
|
|
@ -46,6 +50,7 @@ type AgentTaskQueue struct {
|
|||
Result []byte `json:"result"`
|
||||
Error pgtype.Text `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
Context []byte `json:"context"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
|
|
|
|||
|
|
@ -9,22 +9,112 @@ WHERE id = $1;
|
|||
|
||||
-- name: CreateAgent :one
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, avatar_url, runtime_mode,
|
||||
runtime_config, visibility, max_concurrent_tasks, owner_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
workspace_id, name, description, avatar_url, runtime_mode,
|
||||
runtime_config, visibility, max_concurrent_tasks, owner_id,
|
||||
skills, tools, triggers
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateAgent :one
|
||||
UPDATE agent SET
|
||||
name = COALESCE(sqlc.narg('name'), name),
|
||||
description = COALESCE(sqlc.narg('description'), description),
|
||||
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
|
||||
runtime_config = COALESCE(sqlc.narg('runtime_config'), runtime_config),
|
||||
visibility = COALESCE(sqlc.narg('visibility'), visibility),
|
||||
status = COALESCE(sqlc.narg('status'), status),
|
||||
max_concurrent_tasks = COALESCE(sqlc.narg('max_concurrent_tasks'), max_concurrent_tasks),
|
||||
skills = COALESCE(sqlc.narg('skills'), skills),
|
||||
tools = COALESCE(sqlc.narg('tools'), tools),
|
||||
triggers = COALESCE(sqlc.narg('triggers'), triggers),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteAgent :exec
|
||||
DELETE FROM agent WHERE id = $1;
|
||||
|
||||
-- name: ListAgentTasks :many
|
||||
SELECT * FROM agent_task_queue
|
||||
WHERE agent_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: CreateAgentTask :one
|
||||
INSERT INTO agent_task_queue (agent_id, issue_id, status, priority)
|
||||
VALUES ($1, $2, 'queued', $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CancelAgentTasksByIssue :exec
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled'
|
||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
||||
|
||||
-- name: GetAgentTask :one
|
||||
SELECT * FROM agent_task_queue
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateAgentTaskWithContext :one
|
||||
INSERT INTO agent_task_queue (agent_id, issue_id, status, priority, context)
|
||||
VALUES ($1, $2, 'queued', $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClaimAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'dispatched', dispatched_at = now()
|
||||
WHERE id = (
|
||||
SELECT atq.id FROM agent_task_queue atq
|
||||
WHERE atq.agent_id = $1 AND atq.status = 'queued'
|
||||
ORDER BY atq.priority DESC, atq.created_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: StartAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'running', started_at = now()
|
||||
WHERE id = $1 AND status = 'dispatched'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CompleteAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'completed', completed_at = now(), result = $2
|
||||
WHERE id = $1 AND status = 'running'
|
||||
RETURNING *;
|
||||
|
||||
-- name: FailAgentTask :one
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'failed', completed_at = now(), error = $2
|
||||
WHERE id = $1 AND status = 'running'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CountRunningTasks :one
|
||||
SELECT count(*) FROM agent_task_queue
|
||||
WHERE agent_id = $1 AND status IN ('dispatched', 'running');
|
||||
|
||||
-- name: ListPendingTasks :many
|
||||
SELECT * FROM agent_task_queue
|
||||
WHERE agent_id = $1 AND status IN ('queued', 'dispatched')
|
||||
ORDER BY priority DESC, created_at ASC;
|
||||
|
||||
-- name: UpdateAgentStatus :one
|
||||
UPDATE agent SET status = $2, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertDaemonConnection :one
|
||||
INSERT INTO daemon_connection (agent_id, daemon_id, status, last_heartbeat_at, runtime_info)
|
||||
VALUES ($1, $2, 'connected', now(), $3)
|
||||
ON CONFLICT ON CONSTRAINT uq_daemon_agent
|
||||
DO UPDATE SET status = 'connected', last_heartbeat_at = now(), runtime_info = $3, updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateDaemonHeartbeat :exec
|
||||
UPDATE daemon_connection
|
||||
SET last_heartbeat_at = now(), updated_at = now()
|
||||
WHERE daemon_id = $1 AND agent_id = $2;
|
||||
|
||||
-- name: DisconnectDaemon :exec
|
||||
UPDATE daemon_connection
|
||||
SET status = 'disconnected', updated_at = now()
|
||||
WHERE daemon_id = $1;
|
||||
|
|
|
|||
|
|
@ -31,5 +31,12 @@ UPDATE issue SET
|
|||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateIssueStatus :one
|
||||
UPDATE issue SET
|
||||
status = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteIssue :exec
|
||||
DELETE FROM issue WHERE id = $1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue