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;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -337,11 +338,11 @@ function AgentListItem({
|
|||
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="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" ? (
|
||||
<Cloud className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
|
|
@ -349,8 +350,14 @@ function AgentListItem({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
{isArchived ? (
|
||||
<span className="text-xs text-muted-foreground">Archived</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -1366,30 +1373,50 @@ function AgentDetail({
|
|||
agent,
|
||||
runtimes,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onRestore,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimes: RuntimeDevice[];
|
||||
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 runtimeDevice = getRuntimeDevice(agent, runtimes);
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold truncate">{agent.name}</h2>
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
<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={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</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">
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3" />
|
||||
|
|
@ -1400,24 +1427,26 @@ function AgentDetail({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
{!isArchived && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
@ -1471,33 +1500,33 @@ function AgentDetail({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{confirmDelete && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
{/* Archive Confirmation */}
|
||||
{confirmArchive && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<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">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<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">
|
||||
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>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
onDelete(agent.id);
|
||||
setConfirmArchive(false);
|
||||
onArchive(agent.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
Archive
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -1552,17 +1581,23 @@ export default function AgentsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.deleteAgent(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = agents.filter((a) => a.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
}
|
||||
await api.archiveAgent(id);
|
||||
await refreshAgents();
|
||||
toast.success("Agent deleted");
|
||||
toast.success("Agent archived");
|
||||
} 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}
|
||||
runtimes={runtimes}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
.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 }));
|
||||
|
||||
const issueItems: MentionItem[] = issues
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export function AssigneePicker({
|
|||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
a.name.toLowerCase().includes(query),
|
||||
!a.archived_at && a.name.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
const isSelected = (type: string, id: string) =>
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
toast.error("Failed to load members");
|
||||
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);
|
||||
toast.error("Failed to load agents");
|
||||
return [] as Agent[];
|
||||
|
|
@ -154,7 +154,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id });
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
|
||||
set({ agents });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh agents", e);
|
||||
|
|
|
|||
|
|
@ -291,10 +291,11 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
// 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 wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.include_archived) search.set("include_archived", "true");
|
||||
return this.fetch(`/api/agents?${search}`);
|
||||
}
|
||||
|
||||
|
|
@ -316,8 +317,12 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
async deleteAgent(id: string): Promise<void> {
|
||||
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
||||
async archiveAgent(id: string): Promise<Agent> {
|
||||
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[]> {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ export interface Agent {
|
|||
triggers: AgentTrigger[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export type WSEventType =
|
|||
| "comment:deleted"
|
||||
| "agent:status"
|
||||
| "agent:created"
|
||||
| "agent:deleted"
|
||||
| "agent:archived"
|
||||
| "agent:restored"
|
||||
| "task:dispatch"
|
||||
| "task:progress"
|
||||
| "task:completed"
|
||||
|
|
@ -71,9 +72,12 @@ export interface AgentCreatedPayload {
|
|||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface AgentDeletedPayload {
|
||||
agent_id: string;
|
||||
workspace_id: string;
|
||||
export interface AgentArchivedPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface AgentRestoredPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
export interface InboxNewPayload {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ export const mockAgents: Agent[] = [
|
|||
triggers: [],
|
||||
created_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.Get("/", h.GetAgent)
|
||||
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("/skills", h.ListAgentSkills)
|
||||
r.Put("/skills", h.SetAgentSkills)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ type AgentResponse struct {
|
|||
Triggers any `json:"triggers"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ArchivedAt *string `json:"archived_at"`
|
||||
ArchivedBy *string `json:"archived_by"`
|
||||
}
|
||||
|
||||
func agentToResponse(a db.Agent) AgentResponse {
|
||||
|
|
@ -78,6 +80,8 @@ func agentToResponse(a db.Agent) AgentResponse {
|
|||
Triggers: triggers,
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
||||
return
|
||||
|
|
@ -317,7 +327,7 @@ type UpdateAgentRequest struct {
|
|||
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,
|
||||
// regardless of whether it is public or private.
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
agent, ok := h.loadAgentForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsID := uuidToString(agent.WorkspaceID)
|
||||
|
||||
if !h.canManageAgent(w, r, agent) {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.Queries.DeleteAgent(r.Context(), parseUUID(id))
|
||||
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")
|
||||
if agent.ArchivedAt.Valid {
|
||||
writeError(w, http.StatusConflict, "agent is already archived")
|
||||
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)
|
||||
actorType, actorID := h.resolveActor(r, userID, wsID)
|
||||
h.publish(protocol.EventAgentDeleted, wsID, actorType, actorID, map[string]any{"agent_id": id, "workspace_id": wsID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
h.publish(protocol.EventAgentRestored, wsID, actorType, actorID, map[string]any{"agent": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
if err != nil || !agent.RuntimeID.Valid {
|
||||
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||||
continue
|
||||
}
|
||||
// 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 {
|
||||
return false, "agent not found"
|
||||
}
|
||||
if agent.ArchivedAt.Valid {
|
||||
return false, "cannot assign to archived agent"
|
||||
}
|
||||
if agent.Visibility != "private" {
|
||||
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)
|
||||
if err != nil || !agent.RuntimeID.Valid {
|
||||
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
||||
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)
|
||||
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 {
|
||||
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")
|
||||
|
|
@ -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)
|
||||
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 {
|
||||
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")
|
||||
|
|
@ -549,5 +557,7 @@ func agentToMap(a db.Agent) map[string]any {
|
|||
"triggers": triggers,
|
||||
"created_at": util.TimestampToString(a.CreatedAt),
|
||||
"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"
|
||||
)
|
||||
|
||||
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
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled', completed_at = now()
|
||||
|
|
@ -42,6 +80,17 @@ func (q *Queries) CancelAgentTask(ctx context.Context, id pgtype.UUID) (AgentTas
|
|||
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
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'cancelled'
|
||||
|
|
@ -160,7 +209,7 @@ INSERT INTO agent (
|
|||
runtime_config, runtime_id, visibility, max_concurrent_tasks, owner_id,
|
||||
tools, triggers, instructions
|
||||
) 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 {
|
||||
|
|
@ -214,6 +263,8 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent
|
|||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -262,15 +313,6 @@ func (q *Queries) CreateAgentTask(ctx context.Context, arg CreateAgentTaskParams
|
|||
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
|
||||
UPDATE agent_task_queue
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
|
|
@ -375,12 +417,14 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
|||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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
|
||||
`
|
||||
|
||||
|
|
@ -410,6 +454,8 @@ func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspa
|
|||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -604,8 +650,8 @@ func (q *Queries) ListAgentTasks(ctx context.Context, agentID pgtype.UUID) ([]Ag
|
|||
}
|
||||
|
||||
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
|
||||
WHERE workspace_id = $1
|
||||
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 AND archived_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
|
|
@ -636,6 +682,54 @@ func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Ag
|
|||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -733,6 +827,39 @@ func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]
|
|||
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
|
||||
UPDATE agent_task_queue
|
||||
SET status = 'running', started_at = now()
|
||||
|
|
@ -780,7 +907,7 @@ UPDATE agent SET
|
|||
instructions = COALESCE($13, instructions),
|
||||
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
|
||||
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 {
|
||||
|
|
@ -834,6 +961,8 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
|||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -841,7 +970,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent
|
|||
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, 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 {
|
||||
|
|
@ -870,6 +999,8 @@ func (q *Queries) UpdateAgentStatus(ctx context.Context, arg UpdateAgentStatusPa
|
|||
&i.Triggers,
|
||||
&i.RuntimeID,
|
||||
&i.Instructions,
|
||||
&i.ArchivedAt,
|
||||
&i.ArchivedBy,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ type Agent struct {
|
|||
Triggers []byte `json:"triggers"`
|
||||
RuntimeID pgtype.UUID `json:"runtime_id"`
|
||||
Instructions string `json:"instructions"`
|
||||
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
|
||||
ArchivedBy pgtype.UUID `json:"archived_by"`
|
||||
}
|
||||
|
||||
type AgentRuntime struct {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
-- name: ListAgents :many
|
||||
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
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
|
|
@ -37,8 +42,15 @@ UPDATE agent SET
|
|||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteAgent :exec
|
||||
DELETE FROM agent WHERE id = $1;
|
||||
-- name: ArchiveAgent :one
|
||||
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
|
||||
SELECT * FROM agent_task_queue
|
||||
|
|
@ -55,6 +67,11 @@ UPDATE agent_task_queue
|
|||
SET status = 'cancelled'
|
||||
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
|
||||
SELECT * FROM agent_task_queue
|
||||
WHERE id = $1;
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ const (
|
|||
EventIssueReactionRemoved = "issue_reaction:removed"
|
||||
|
||||
// Agent events
|
||||
EventAgentStatus = "agent:status"
|
||||
EventAgentCreated = "agent:created"
|
||||
EventAgentDeleted = "agent:deleted"
|
||||
EventAgentStatus = "agent:status"
|
||||
EventAgentCreated = "agent:created"
|
||||
EventAgentArchived = "agent:archived"
|
||||
EventAgentRestored = "agent:restored"
|
||||
|
||||
// Task events (server <-> daemon)
|
||||
EventTaskDispatch = "task:dispatch"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue