feat(agent): replace hard delete with archive/restore (#346)
* feat(agent): replace hard delete with archive/restore
Replace agent deletion with soft archive pattern. Archived agents
are preserved in the database with all historical references intact
but cannot be assigned, mentioned, or trigger tasks.
Backend:
- Add archived_at/archived_by columns to agent table (migration 031)
- Replace DELETE /api/agents/{id} with POST /api/agents/{id}/archive
- Add POST /api/agents/{id}/restore endpoint
- ListAgents excludes archived by default (?include_archived=true to include)
- Skip archived agents in task triggers (on_assign, on_comment, on_mention)
- Block assignment to archived agents
- Cancel pending tasks on archive
- New events: agent:archived, agent:restored (replacing agent:deleted)
Frontend:
- Agent type includes archived_at/archived_by fields
- Mention autocomplete and assignee picker filter out archived agents
- Agent list shows archived agents with muted styling
- Agent detail shows archive banner with restore button
- Delete button replaced with Archive button and updated confirmation dialog
- API client: archiveAgent/restoreAgent replace deleteAgent
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(agent): self-review fixes for archive feature
- Fix: workspace store now fetches agents with include_archived=true
so archived agents are actually visible in the frontend (the archived
UI was dead code before — ListAgents excludes archived by default)
- Fix: add error logging for CancelAgentTasksByAgent in ArchiveAgent
- Fix: add idempotency guards — return 409 Conflict when archiving
an already-archived agent or restoring a non-archived agent
- Fix: revert unnecessary extra GetAgent query in ReconcileAgentStatus
(archived agents won't have running tasks after CancelAgentTasksByAgent)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
565afed447
commit
09764c5f51
19 changed files with 369 additions and 97 deletions
|
|
@ -329,6 +329,7 @@ function AgentListItem({
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const st = statusConfig[agent.status];
|
const st = statusConfig[agent.status];
|
||||||
|
const isArchived = !!agent.archived_at;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -337,11 +338,11 @@ function AgentListItem({
|
||||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className="rounded-lg" />
|
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} />
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium">{agent.name}</span>
|
<span className={`truncate text-sm font-medium ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</span>
|
||||||
{agent.runtime_mode === "cloud" ? (
|
{agent.runtime_mode === "cloud" ? (
|
||||||
<Cloud className="h-3 w-3 text-muted-foreground" />
|
<Cloud className="h-3 w-3 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -349,8 +350,14 @@ function AgentListItem({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
{isArchived ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Archived</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1366,30 +1373,50 @@ function AgentDetail({
|
||||||
agent,
|
agent,
|
||||||
runtimes,
|
runtimes,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onArchive,
|
||||||
|
onRestore,
|
||||||
}: {
|
}: {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
runtimes: RuntimeDevice[];
|
runtimes: RuntimeDevice[];
|
||||||
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onArchive: (id: string) => Promise<void>;
|
||||||
|
onRestore: (id: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const st = statusConfig[agent.status];
|
const st = statusConfig[agent.status];
|
||||||
const runtimeDevice = getRuntimeDevice(agent, runtimes);
|
const runtimeDevice = getRuntimeDevice(agent, runtimes);
|
||||||
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||||
|
const isArchived = !!agent.archived_at;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Archive Banner */}
|
||||||
|
{isArchived && (
|
||||||
|
<div className="flex items-center gap-2 bg-muted/50 px-4 py-2 text-xs text-muted-foreground border-b">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="flex-1">This agent is archived. It cannot be assigned or mentioned.</span>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={() => onRestore(agent.id)}>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||||
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className="rounded-md" />
|
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-semibold truncate">{agent.name}</h2>
|
<h2 className={`text-sm font-semibold truncate ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</h2>
|
||||||
|
{isArchived ? (
|
||||||
|
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
Archived
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||||
{st.label}
|
{st.label}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
{agent.runtime_mode === "cloud" ? (
|
{agent.runtime_mode === "cloud" ? (
|
||||||
<Cloud className="h-3 w-3" />
|
<Cloud className="h-3 w-3" />
|
||||||
|
|
@ -1400,6 +1427,7 @@ function AgentDetail({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!isArchived && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
|
|
@ -1411,13 +1439,14 @@ function AgentDetail({
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmArchive(true)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Delete Agent
|
Archive Agent
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
|
|
@ -1471,33 +1500,33 @@ function AgentDetail({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Archive Confirmation */}
|
||||||
{confirmDelete && (
|
{confirmArchive && (
|
||||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
|
||||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<DialogHeader className="flex-1 gap-1">
|
<DialogHeader className="flex-1 gap-1">
|
||||||
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">Archive agent?</DialogTitle>
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
This will permanently delete "{agent.name}" and all its configuration.
|
"{agent.name}" will be archived. It won't be assignable or mentionable, but all history is preserved. You can restore it later.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConfirmDelete(false);
|
setConfirmArchive(false);
|
||||||
onDelete(agent.id);
|
onArchive(agent.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Archive
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -1552,17 +1581,23 @@ export default function AgentsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleArchive = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.deleteAgent(id);
|
await api.archiveAgent(id);
|
||||||
if (selectedId === id) {
|
|
||||||
const remaining = agents.filter((a) => a.id !== id);
|
|
||||||
setSelectedId(remaining[0]?.id ?? "");
|
|
||||||
}
|
|
||||||
await refreshAgents();
|
await refreshAgents();
|
||||||
toast.success("Agent deleted");
|
toast.success("Agent archived");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Failed to delete agent");
|
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.restoreAgent(id);
|
||||||
|
await refreshAgents();
|
||||||
|
toast.success("Agent restored");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1666,7 +1701,8 @@ export default function AgentsPage() {
|
||||||
agent={selected}
|
agent={selected}
|
||||||
runtimes={runtimes}
|
runtimes={runtimes}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onDelete={handleDelete}
|
onArchive={handleArchive}
|
||||||
|
onRestore={handleRestore}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ export function createMentionSuggestion(): Omit<
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const agentItems: MentionItem[] = agents
|
const agentItems: MentionItem[] = agents
|
||||||
.filter((a) => a.name.toLowerCase().includes(q))
|
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
|
||||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||||
|
|
||||||
const issueItems: MentionItem[] = issues
|
const issueItems: MentionItem[] = issues
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export function AssigneePicker({
|
||||||
m.name.toLowerCase().includes(query),
|
m.name.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
const filteredAgents = agents.filter((a) =>
|
const filteredAgents = agents.filter((a) =>
|
||||||
a.name.toLowerCase().includes(query),
|
!a.archived_at && a.name.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSelected = (type: string, id: string) =>
|
const isSelected = (type: string, id: string) =>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||||
toast.error("Failed to load members");
|
toast.error("Failed to load members");
|
||||||
return [] as MemberWithUser[];
|
return [] as MemberWithUser[];
|
||||||
}),
|
}),
|
||||||
api.listAgents({ workspace_id: nextWorkspace.id }).catch((e) => {
|
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
|
||||||
logger.error("failed to load agents", e);
|
logger.error("failed to load agents", e);
|
||||||
toast.error("Failed to load agents");
|
toast.error("Failed to load agents");
|
||||||
return [] as Agent[];
|
return [] as Agent[];
|
||||||
|
|
@ -154,7 +154,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||||
const { workspace } = get();
|
const { workspace } = get();
|
||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
try {
|
try {
|
||||||
const agents = await api.listAgents({ workspace_id: workspace.id });
|
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
|
||||||
set({ agents });
|
set({ agents });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("failed to refresh agents", e);
|
logger.error("failed to refresh agents", e);
|
||||||
|
|
|
||||||
|
|
@ -291,10 +291,11 @@ export class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
|
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||||
if (wsId) search.set("workspace_id", wsId);
|
if (wsId) search.set("workspace_id", wsId);
|
||||||
|
if (params?.include_archived) search.set("include_archived", "true");
|
||||||
return this.fetch(`/api/agents?${search}`);
|
return this.fetch(`/api/agents?${search}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,8 +317,12 @@ export class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAgent(id: string): Promise<void> {
|
async archiveAgent(id: string): Promise<Agent> {
|
||||||
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreAgent(id: string): Promise<Agent> {
|
||||||
|
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
|
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ export interface Agent {
|
||||||
triggers: AgentTrigger[];
|
triggers: AgentTrigger[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
archived_at: string | null;
|
||||||
|
archived_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAgentRequest {
|
export interface CreateAgentRequest {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ export type WSEventType =
|
||||||
| "comment:deleted"
|
| "comment:deleted"
|
||||||
| "agent:status"
|
| "agent:status"
|
||||||
| "agent:created"
|
| "agent:created"
|
||||||
| "agent:deleted"
|
| "agent:archived"
|
||||||
|
| "agent:restored"
|
||||||
| "task:dispatch"
|
| "task:dispatch"
|
||||||
| "task:progress"
|
| "task:progress"
|
||||||
| "task:completed"
|
| "task:completed"
|
||||||
|
|
@ -71,9 +72,12 @@ export interface AgentCreatedPayload {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentDeletedPayload {
|
export interface AgentArchivedPayload {
|
||||||
agent_id: string;
|
agent: Agent;
|
||||||
workspace_id: string;
|
}
|
||||||
|
|
||||||
|
export interface AgentRestoredPayload {
|
||||||
|
agent: Agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InboxNewPayload {
|
export interface InboxNewPayload {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ export const mockAgents: Agent[] = [
|
||||||
triggers: [],
|
triggers: [],
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
archived_at: null,
|
||||||
|
archived_by: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
r.Route("/{id}", func(r chi.Router) {
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
r.Get("/", h.GetAgent)
|
r.Get("/", h.GetAgent)
|
||||||
r.Put("/", h.UpdateAgent)
|
r.Put("/", h.UpdateAgent)
|
||||||
r.Delete("/", h.DeleteAgent)
|
r.Post("/archive", h.ArchiveAgent)
|
||||||
|
r.Post("/restore", h.RestoreAgent)
|
||||||
r.Get("/tasks", h.ListAgentTasks)
|
r.Get("/tasks", h.ListAgentTasks)
|
||||||
r.Get("/skills", h.ListAgentSkills)
|
r.Get("/skills", h.ListAgentSkills)
|
||||||
r.Put("/skills", h.SetAgentSkills)
|
r.Put("/skills", h.SetAgentSkills)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ type AgentResponse struct {
|
||||||
Triggers any `json:"triggers"`
|
Triggers any `json:"triggers"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
ArchivedAt *string `json:"archived_at"`
|
||||||
|
ArchivedBy *string `json:"archived_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func agentToResponse(a db.Agent) AgentResponse {
|
func agentToResponse(a db.Agent) AgentResponse {
|
||||||
|
|
@ -78,6 +80,8 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||||
Triggers: triggers,
|
Triggers: triggers,
|
||||||
CreatedAt: timestampToString(a.CreatedAt),
|
CreatedAt: timestampToString(a.CreatedAt),
|
||||||
UpdatedAt: timestampToString(a.UpdatedAt),
|
UpdatedAt: timestampToString(a.UpdatedAt),
|
||||||
|
ArchivedAt: timestampToPtr(a.ArchivedAt),
|
||||||
|
ArchivedBy: uuidToPtr(a.ArchivedBy),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,7 +150,13 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agents, err := h.Queries.ListAgents(r.Context(), parseUUID(workspaceID))
|
var agents []db.Agent
|
||||||
|
var err error
|
||||||
|
if r.URL.Query().Get("include_archived") == "true" {
|
||||||
|
agents, err = h.Queries.ListAllAgents(r.Context(), parseUUID(workspaceID))
|
||||||
|
} else {
|
||||||
|
agents, err = h.Queries.ListAgents(r.Context(), parseUUID(workspaceID))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
||||||
return
|
return
|
||||||
|
|
@ -317,7 +327,7 @@ type UpdateAgentRequest struct {
|
||||||
Triggers any `json:"triggers"`
|
Triggers any `json:"triggers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// canManageAgent checks whether the current user can update or delete an agent.
|
// canManageAgent checks whether the current user can update or archive an agent.
|
||||||
// Only the agent owner or workspace owner/admin can manage any agent,
|
// Only the agent owner or workspace owner/admin can manage any agent,
|
||||||
// regardless of whether it is public or private.
|
// regardless of whether it is public or private.
|
||||||
func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool {
|
func (h *Handler) canManageAgent(w http.ResponseWriter, r *http.Request, agent db.Agent) bool {
|
||||||
|
|
@ -415,30 +425,72 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ArchiveAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
agent, ok := h.loadAgentForUser(w, r, id)
|
agent, ok := h.loadAgentForUser(w, r, id)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
wsID := uuidToString(agent.WorkspaceID)
|
|
||||||
|
|
||||||
if !h.canManageAgent(w, r, agent) {
|
if !h.canManageAgent(w, r, agent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if agent.ArchivedAt.Valid {
|
||||||
err := h.Queries.DeleteAgent(r.Context(), parseUUID(id))
|
writeError(w, http.StatusConflict, "agent is already archived")
|
||||||
if err != nil {
|
|
||||||
slog.Warn("delete agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete agent")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("agent deleted", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
|
userID := requestUserID(r)
|
||||||
|
archived, err := h.Queries.ArchiveAgent(r.Context(), db.ArchiveAgentParams{
|
||||||
|
ID: parseUUID(id),
|
||||||
|
ArchivedBy: parseUUID(userID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("archive agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to archive agent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel all pending/active tasks for this agent.
|
||||||
|
if err := h.Queries.CancelAgentTasksByAgent(r.Context(), parseUUID(id)); err != nil {
|
||||||
|
slog.Warn("cancel agent tasks on archive failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
wsID := uuidToString(archived.WorkspaceID)
|
||||||
|
slog.Info("agent archived", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
|
||||||
|
resp := agentToResponse(archived)
|
||||||
|
actorType, actorID := h.resolveActor(r, userID, wsID)
|
||||||
|
h.publish(protocol.EventAgentArchived, wsID, actorType, actorID, map[string]any{"agent": resp})
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RestoreAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
agent, ok := h.loadAgentForUser(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.canManageAgent(w, r, agent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !agent.ArchivedAt.Valid {
|
||||||
|
writeError(w, http.StatusConflict, "agent is not archived")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := h.Queries.RestoreAgent(r.Context(), parseUUID(id))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("restore agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to restore agent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wsID := uuidToString(restored.WorkspaceID)
|
||||||
|
slog.Info("agent restored", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...)
|
||||||
|
resp := agentToResponse(restored)
|
||||||
userID := requestUserID(r)
|
userID := requestUserID(r)
|
||||||
actorType, actorID := h.resolveActor(r, userID, wsID)
|
actorType, actorID := h.resolveActor(r, userID, wsID)
|
||||||
h.publish(protocol.EventAgentDeleted, wsID, actorType, actorID, map[string]any{"agent_id": id, "workspace_id": wsID})
|
h.publish(protocol.EventAgentRestored, wsID, actorType, actorID, map[string]any{"agent": resp})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -273,9 +273,9 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue
|
||||||
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
|
issue.AssigneeID.Valid && uuidToString(issue.AssigneeID) == m.ID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Load the agent to check visibility and trigger config.
|
// Load the agent to check visibility, archive status, and trigger config.
|
||||||
agent, err := h.Queries.GetAgent(ctx, agentUUID)
|
agent, err := h.Queries.GetAgent(ctx, agentUUID)
|
||||||
if err != nil || !agent.RuntimeID.Valid {
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Private agents can only be mentioned by the agent owner or workspace admin/owner.
|
// Private agents can only be mentioned by the agent owner or workspace admin/owner.
|
||||||
|
|
|
||||||
|
|
@ -466,6 +466,9 @@ func (h *Handler) canAssignAgent(ctx context.Context, r *http.Request, agentID,
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "agent not found"
|
return false, "agent not found"
|
||||||
}
|
}
|
||||||
|
if agent.ArchivedAt.Valid {
|
||||||
|
return false, "cannot assign to archived agent"
|
||||||
|
}
|
||||||
if agent.Visibility != "private" {
|
if agent.Visibility != "private" {
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
@ -521,7 +524,7 @@ func (h *Handler) isAgentTriggerEnabled(ctx context.Context, issue db.Issue, tri
|
||||||
}
|
}
|
||||||
|
|
||||||
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
|
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
|
||||||
if err != nil || !agent.RuntimeID.Valid {
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,10 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t
|
||||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
||||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||||
}
|
}
|
||||||
|
if agent.ArchivedAt.Valid {
|
||||||
|
slog.Debug("task enqueue skipped: agent is archived", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agent.ID))
|
||||||
|
return db.AgentTaskQueue{}, fmt.Errorf("agent is archived")
|
||||||
|
}
|
||||||
if !agent.RuntimeID.Valid {
|
if !agent.RuntimeID.Valid {
|
||||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "agent has no runtime")
|
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "agent has no runtime")
|
||||||
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
||||||
|
|
@ -79,6 +83,10 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue,
|
||||||
slog.Error("mention task enqueue failed: agent not found", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err)
|
slog.Error("mention task enqueue failed: agent not found", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "error", err)
|
||||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||||
}
|
}
|
||||||
|
if agent.ArchivedAt.Valid {
|
||||||
|
slog.Debug("mention task enqueue skipped: agent is archived", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
|
||||||
|
return db.AgentTaskQueue{}, fmt.Errorf("agent is archived")
|
||||||
|
}
|
||||||
if !agent.RuntimeID.Valid {
|
if !agent.RuntimeID.Valid {
|
||||||
slog.Error("mention task enqueue failed: agent has no runtime", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
|
slog.Error("mention task enqueue failed: agent has no runtime", "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID))
|
||||||
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
||||||
|
|
@ -549,5 +557,7 @@ func agentToMap(a db.Agent) map[string]any {
|
||||||
"triggers": triggers,
|
"triggers": triggers,
|
||||||
"created_at": util.TimestampToString(a.CreatedAt),
|
"created_at": util.TimestampToString(a.CreatedAt),
|
||||||
"updated_at": util.TimestampToString(a.UpdatedAt),
|
"updated_at": util.TimestampToString(a.UpdatedAt),
|
||||||
|
"archived_at": util.TimestampToPtr(a.ArchivedAt),
|
||||||
|
"archived_by": util.UUIDToPtr(a.ArchivedBy),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
server/migrations/031_agent_archive.down.sql
Normal file
2
server/migrations/031_agent_archive.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE agent DROP COLUMN IF EXISTS archived_by;
|
||||||
|
ALTER TABLE agent DROP COLUMN IF EXISTS archived_at;
|
||||||
4
server/migrations/031_agent_archive.up.sql
Normal file
4
server/migrations/031_agent_archive.up.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Add archive support to agents (soft-delete replacement).
|
||||||
|
-- archived_at IS NOT NULL means the agent is archived.
|
||||||
|
ALTER TABLE agent ADD COLUMN archived_at TIMESTAMPTZ DEFAULT NULL;
|
||||||
|
ALTER TABLE agent ADD COLUMN archived_by UUID DEFAULT NULL REFERENCES "user"(id);
|
||||||
|
|
@ -11,6 +11,44 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const archiveAgent = `-- name: ArchiveAgent :one
|
||||||
|
UPDATE agent SET archived_at = now(), archived_by = $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, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||||
|
`
|
||||||
|
|
||||||
|
type ArchiveAgentParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
ArchivedBy pgtype.UUID `json:"archived_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ArchiveAgent(ctx context.Context, arg ArchiveAgentParams) (Agent, error) {
|
||||||
|
row := q.db.QueryRow(ctx, archiveAgent, arg.ID, arg.ArchivedBy)
|
||||||
|
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.Tools,
|
||||||
|
&i.Triggers,
|
||||||
|
&i.RuntimeID,
|
||||||
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const cancelAgentTask = `-- name: CancelAgentTask :one
|
const cancelAgentTask = `-- name: CancelAgentTask :one
|
||||||
UPDATE agent_task_queue
|
UPDATE agent_task_queue
|
||||||
SET status = 'cancelled', completed_at = now()
|
SET status = 'cancelled', completed_at = now()
|
||||||
|
|
@ -42,6 +80,17 @@ func (q *Queries) CancelAgentTask(ctx context.Context, id pgtype.UUID) (AgentTas
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelAgentTasksByAgent = `-- name: CancelAgentTasksByAgent :exec
|
||||||
|
UPDATE agent_task_queue
|
||||||
|
SET status = 'cancelled'
|
||||||
|
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running')
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CancelAgentTasksByAgent(ctx context.Context, agentID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, cancelAgentTasksByAgent, agentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const cancelAgentTasksByIssue = `-- name: CancelAgentTasksByIssue :exec
|
const cancelAgentTasksByIssue = `-- name: CancelAgentTasksByIssue :exec
|
||||||
UPDATE agent_task_queue
|
UPDATE agent_task_queue
|
||||||
SET status = 'cancelled'
|
SET status = 'cancelled'
|
||||||
|
|
@ -160,7 +209,7 @@ INSERT INTO agent (
|
||||||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||||
tools, triggers, instructions
|
tools, triggers, instructions
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions
|
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAgentParams struct {
|
type CreateAgentParams struct {
|
||||||
|
|
@ -214,6 +263,8 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
||||||
&i.Triggers,
|
&i.Triggers,
|
||||||
&i.RuntimeID,
|
&i.RuntimeID,
|
||||||
&i.Instructions,
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -262,15 +313,6 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteAgent = `-- name: DeleteAgent :exec
|
|
||||||
DELETE FROM agent WHERE id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteAgent(ctx context.Context, id pgtype.UUID) error {
|
|
||||||
_, err := q.db.Exec(ctx, deleteAgent, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const failAgentTask = `-- name: FailAgentTask :one
|
const failAgentTask = `-- name: FailAgentTask :one
|
||||||
UPDATE agent_task_queue
|
UPDATE agent_task_queue
|
||||||
SET status = 'failed', completed_at = now(), error = $2
|
SET status = 'failed', completed_at = now(), error = $2
|
||||||
|
|
@ -350,7 +392,7 @@ func (q *Queries) FailStaleTasks(ctx context.Context, arg FailStaleTasksParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAgent = `-- name: GetAgent :one
|
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, description, tools, triggers, runtime_id, instructions 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, tools, triggers, runtime_id, instructions, archived_at, archived_by FROM agent
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -375,12 +417,14 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||||
&i.Triggers,
|
&i.Triggers,
|
||||||
&i.RuntimeID,
|
&i.RuntimeID,
|
||||||
&i.Instructions,
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one
|
const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one
|
||||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions 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, tools, triggers, runtime_id, instructions, archived_at, archived_by FROM agent
|
||||||
WHERE id = $1 AND workspace_id = $2
|
WHERE id = $1 AND workspace_id = $2
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -410,6 +454,8 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
|
||||||
&i.Triggers,
|
&i.Triggers,
|
||||||
&i.RuntimeID,
|
&i.RuntimeID,
|
||||||
&i.Instructions,
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -604,8 +650,8 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
|
||||||
}
|
}
|
||||||
|
|
||||||
const listAgents = `-- name: ListAgents :many
|
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, description, tools, triggers, runtime_id, instructions 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, tools, triggers, runtime_id, instructions, archived_at, archived_by FROM agent
|
||||||
WHERE workspace_id = $1
|
WHERE workspace_id = $1 AND archived_at IS NULL
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -636,6 +682,54 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
||||||
&i.Triggers,
|
&i.Triggers,
|
||||||
&i.RuntimeID,
|
&i.RuntimeID,
|
||||||
&i.Instructions,
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAllAgents = `-- name: ListAllAgents :many
|
||||||
|
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by FROM agent
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListAllAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Agent, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAllAgents, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Agent{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Agent
|
||||||
|
if err := rows.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.Tools,
|
||||||
|
&i.Triggers,
|
||||||
|
&i.RuntimeID,
|
||||||
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -733,6 +827,39 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restoreAgent = `-- name: RestoreAgent :one
|
||||||
|
UPDATE agent SET archived_at = NULL, archived_by = NULL, 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, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) RestoreAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||||
|
row := q.db.QueryRow(ctx, restoreAgent, id)
|
||||||
|
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.Tools,
|
||||||
|
&i.Triggers,
|
||||||
|
&i.RuntimeID,
|
||||||
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const startAgentTask = `-- name: StartAgentTask :one
|
const startAgentTask = `-- name: StartAgentTask :one
|
||||||
UPDATE agent_task_queue
|
UPDATE agent_task_queue
|
||||||
SET status = 'running', started_at = now()
|
SET status = 'running', started_at = now()
|
||||||
|
|
@ -780,7 +907,7 @@ UPDATE agent SET
|
||||||
instructions = COALESCE($13, instructions),
|
instructions = COALESCE($13, instructions),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
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, tools, triggers, runtime_id, instructions
|
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAgentParams struct {
|
type UpdateAgentParams struct {
|
||||||
|
|
@ -834,6 +961,8 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||||
&i.Triggers,
|
&i.Triggers,
|
||||||
&i.RuntimeID,
|
&i.RuntimeID,
|
||||||
&i.Instructions,
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -841,7 +970,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
||||||
const updateAgentStatus = `-- name: UpdateAgentStatus :one
|
const updateAgentStatus = `-- name: UpdateAgentStatus :one
|
||||||
UPDATE agent SET status = $2, updated_at = now()
|
UPDATE agent SET status = $2, updated_at = now()
|
||||||
WHERE id = $1
|
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, tools, triggers, runtime_id, instructions
|
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions, archived_at, archived_by
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateAgentStatusParams struct {
|
type UpdateAgentStatusParams struct {
|
||||||
|
|
@ -870,6 +999,8 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
|
||||||
&i.Triggers,
|
&i.Triggers,
|
||||||
&i.RuntimeID,
|
&i.RuntimeID,
|
||||||
&i.Instructions,
|
&i.Instructions,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ArchivedBy,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ type Agent struct {
|
||||||
Triggers []byte `json:"triggers"`
|
Triggers []byte `json:"triggers"`
|
||||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||||
Instructions string `json:"instructions"`
|
Instructions string `json:"instructions"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
|
||||||
|
ArchivedBy pgtype.UUID `json:"archived_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentRuntime struct {
|
type AgentRuntime struct {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
-- name: ListAgents :many
|
-- name: ListAgents :many
|
||||||
SELECT * FROM agent
|
SELECT * FROM agent
|
||||||
|
WHERE workspace_id = $1 AND archived_at IS NULL
|
||||||
|
ORDER BY created_at ASC;
|
||||||
|
|
||||||
|
-- name: ListAllAgents :many
|
||||||
|
SELECT * FROM agent
|
||||||
WHERE workspace_id = $1
|
WHERE workspace_id = $1
|
||||||
ORDER BY created_at ASC;
|
ORDER BY created_at ASC;
|
||||||
|
|
||||||
|
|
@ -37,8 +42,15 @@ UPDATE agent SET
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: DeleteAgent :exec
|
-- name: ArchiveAgent :one
|
||||||
DELETE FROM agent WHERE id = $1;
|
UPDATE agent SET archived_at = now(), archived_by = $2, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: RestoreAgent :one
|
||||||
|
UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ListAgentTasks :many
|
-- name: ListAgentTasks :many
|
||||||
SELECT * FROM agent_task_queue
|
SELECT * FROM agent_task_queue
|
||||||
|
|
@ -55,6 +67,11 @@ UPDATE agent_task_queue
|
||||||
SET status = 'cancelled'
|
SET status = 'cancelled'
|
||||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
||||||
|
|
||||||
|
-- name: CancelAgentTasksByAgent :exec
|
||||||
|
UPDATE agent_task_queue
|
||||||
|
SET status = 'cancelled'
|
||||||
|
WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running');
|
||||||
|
|
||||||
-- name: GetAgentTask :one
|
-- name: GetAgentTask :one
|
||||||
SELECT * FROM agent_task_queue
|
SELECT * FROM agent_task_queue
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ const (
|
||||||
// Agent events
|
// Agent events
|
||||||
EventAgentStatus = "agent:status"
|
EventAgentStatus = "agent:status"
|
||||||
EventAgentCreated = "agent:created"
|
EventAgentCreated = "agent:created"
|
||||||
EventAgentDeleted = "agent:deleted"
|
EventAgentArchived = "agent:archived"
|
||||||
|
EventAgentRestored = "agent:restored"
|
||||||
|
|
||||||
// Task events (server <-> daemon)
|
// Task events (server <-> daemon)
|
||||||
EventTaskDispatch = "task:dispatch"
|
EventTaskDispatch = "task:dispatch"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue