Merge pull request #239 from multica-ai/forrestchang/jakarta

feat: agent management UI + task service layer + daemon protocol
This commit is contained in:
Jiayuan Zhang 2026-03-23 18:58:03 +08:00 committed by GitHub
commit fff7753a0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2809 additions and 220 deletions

File diff suppressed because it is too large Load diff

View file

@ -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 ? (

View file

@ -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",
},

View file

@ -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",
},

View file

@ -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");

View file

@ -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[];
}

View file

@ -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";

View file

@ -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

View file

@ -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)
})
})

View file

@ -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)
}

View 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))
}

View file

@ -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) {

View file

@ -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 {

View 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),
}
}

View 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
}

View 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;

View 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 '[]';

View 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;

View 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);

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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;

View file

@ -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;