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:
LinYushen 2026-04-02 17:33:52 +08:00 committed by GitHub
parent 565afed447
commit 09764c5f51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 369 additions and 97 deletions

View file

@ -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 &quot;{agent.name}&quot; and all its configuration. &quot;{agent.name}&quot; will be archived. It won&apos;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">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
ALTER TABLE agent DROP COLUMN IF EXISTS archived_by;
ALTER TABLE agent DROP COLUMN IF EXISTS archived_at;

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

View file

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

View file

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

View file

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

View file

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