From 09764c5f51d933d8aa228e440c64f809428b1592 Mon Sep 17 00:00:00 2001 From: LinYushen Date: Thu, 2 Apr 2026 17:33:52 +0800 Subject: [PATCH] feat(agent): replace hard delete with archive/restore (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- apps/web/app/(dashboard)/agents/page.tsx | 132 ++++++++------ .../components/common/mention-suggestion.tsx | 2 +- .../components/pickers/assignee-picker.tsx | 2 +- apps/web/features/workspace/store.ts | 4 +- apps/web/shared/api/client.ts | 11 +- apps/web/shared/types/agent.ts | 2 + apps/web/shared/types/events.ts | 12 +- apps/web/test/helpers.tsx | 2 + server/cmd/server/router.go | 3 +- server/internal/handler/agent.go | 78 +++++++-- server/internal/handler/comment.go | 4 +- server/internal/handler/issue.go | 5 +- server/internal/service/task.go | 10 ++ server/migrations/031_agent_archive.down.sql | 2 + server/migrations/031_agent_archive.up.sql | 4 + server/pkg/db/generated/agent.sql.go | 163 ++++++++++++++++-- server/pkg/db/generated/models.go | 2 + server/pkg/db/queries/agent.sql | 21 ++- server/pkg/protocol/events.go | 7 +- 19 files changed, 369 insertions(+), 97 deletions(-) create mode 100644 server/migrations/031_agent_archive.down.sql create mode 100644 server/migrations/031_agent_archive.up.sql diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index e9fb3d09..478edd46 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -329,6 +329,7 @@ function AgentListItem({ onClick: () => void; }) { const st = statusConfig[agent.status]; + const isArchived = !!agent.archived_at; return ( @@ -1366,30 +1373,50 @@ function AgentDetail({ agent, runtimes, onUpdate, - onDelete, + onArchive, + onRestore, }: { agent: Agent; runtimes: RuntimeDevice[]; onUpdate: (id: string, data: Partial) => Promise; - onDelete: (id: string) => Promise; + onArchive: (id: string) => Promise; + onRestore: (id: string) => Promise; }) { const st = statusConfig[agent.status]; const runtimeDevice = getRuntimeDevice(agent, runtimes); const [activeTab, setActiveTab] = useState("instructions"); - const [confirmDelete, setConfirmDelete] = useState(false); + const [confirmArchive, setConfirmArchive] = useState(false); + const isArchived = !!agent.archived_at; return (
+ {/* Archive Banner */} + {isArchived && ( +
+ + This agent is archived. It cannot be assigned or mentioned. + +
+ )} + {/* Header */}
- +
-

{agent.name}

- - - {st.label} - +

{agent.name}

+ {isArchived ? ( + + Archived + + ) : ( + + + {st.label} + + )} {agent.runtime_mode === "cloud" ? ( @@ -1400,24 +1427,26 @@ function AgentDetail({
- - - } - > - - - - setConfirmDelete(true)} + {!isArchived && ( + + + } > - - Delete Agent - - - + + + + setConfirmArchive(true)} + > + + Archive Agent + + + + )}
{/* Tabs */} @@ -1471,33 +1500,33 @@ function AgentDetail({ )}
- {/* Delete Confirmation */} - {confirmDelete && ( - { if (!v) setConfirmDelete(false); }}> + {/* Archive Confirmation */} + {confirmArchive && ( + { if (!v) setConfirmArchive(false); }}>
- Delete agent? + Archive agent? - 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.
-
@@ -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} /> ) : (
diff --git a/apps/web/components/common/mention-suggestion.tsx b/apps/web/components/common/mention-suggestion.tsx index 74806f83..67e65346 100644 --- a/apps/web/components/common/mention-suggestion.tsx +++ b/apps/web/components/common/mention-suggestion.tsx @@ -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 diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index c75589ba..3bc3a70f 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -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) => diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts index 591cc7b9..ea18ed07 100644 --- a/apps/web/features/workspace/store.ts +++ b/apps/web/features/workspace/store.ts @@ -82,7 +82,7 @@ export const useWorkspaceStore = create((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((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); diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 5314e978..4721ccf3 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -291,10 +291,11 @@ export class ApiClient { } // Agents - async listAgents(params?: { workspace_id?: string }): Promise { + async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise { 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 { - await this.fetch(`/api/agents/${id}`, { method: "DELETE" }); + async archiveAgent(id: string): Promise { + return this.fetch(`/api/agents/${id}/archive`, { method: "POST" }); + } + + async restoreAgent(id: string): Promise { + return this.fetch(`/api/agents/${id}/restore`, { method: "POST" }); } async listRuntimes(params?: { workspace_id?: string }): Promise { diff --git a/apps/web/shared/types/agent.ts b/apps/web/shared/types/agent.ts index 232b4da4..9f596ab4 100644 --- a/apps/web/shared/types/agent.ts +++ b/apps/web/shared/types/agent.ts @@ -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 { diff --git a/apps/web/shared/types/events.ts b/apps/web/shared/types/events.ts index 8b3a5fc6..3116f03d 100644 --- a/apps/web/shared/types/events.ts +++ b/apps/web/shared/types/events.ts @@ -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 { diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index 6a01ae7a..94988fe1 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -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, }, ]; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 509826e1..acb6583d 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -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) diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index d81c47e9..bcfb3a6d 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -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) { diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 5e0e4c59..a40847fa 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -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. diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 3a74d830..418252e2 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -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 } diff --git a/server/internal/service/task.go b/server/internal/service/task.go index d4026ff6..92d602cf 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -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), } } diff --git a/server/migrations/031_agent_archive.down.sql b/server/migrations/031_agent_archive.down.sql new file mode 100644 index 00000000..5f547c22 --- /dev/null +++ b/server/migrations/031_agent_archive.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE agent DROP COLUMN IF EXISTS archived_by; +ALTER TABLE agent DROP COLUMN IF EXISTS archived_at; diff --git a/server/migrations/031_agent_archive.up.sql b/server/migrations/031_agent_archive.up.sql new file mode 100644 index 00000000..49fc0660 --- /dev/null +++ b/server/migrations/031_agent_archive.up.sql @@ -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); diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index 06984472..bc785374 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -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 } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 7c812e50..1aa34fe7 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -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 { diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index b55476b8..95239b2f 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -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; diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index 7a8636ec..d48cd6f5 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -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"