From ff27a249ccb7c43df308f8a5bcb5ecd8b4680f2f Mon Sep 17 00:00:00 2001 From: LinYushen Date: Thu, 9 Apr 2026 13:38:46 +0800 Subject: [PATCH] feat(runtime): add owner tracking, filtering, and delete (#535) Add owner_id to agent_runtime table to track who registered each runtime. Backend: new delete endpoint with role-based permissions (owner/admin can delete any, members only their own), list filtering by owner (?owner=me), and agent dependency check before deletion. Frontend: Mine/All filter toggle in runtime list, owner display in list items and detail view, delete button with AlertDialog confirmation. Co-authored-by: Claude Opus 4.6 (1M context) --- apps/web/core/runtimes/index.ts | 1 + apps/web/core/runtimes/mutations.ts | 13 +++ apps/web/core/runtimes/queries.ts | 7 +- .../runtimes/components/runtime-detail.tsx | 93 ++++++++++++++++++- .../runtimes/components/runtime-list.tsx | 48 +++++++++- .../runtimes/components/runtimes-page.tsx | 11 ++- apps/web/shared/api/client.ts | 7 +- apps/web/shared/types/agent.ts | 1 + server/cmd/server/router.go | 15 +-- server/internal/handler/daemon.go | 4 +- server/internal/handler/runtime.go | 72 +++++++++++++- server/migrations/032_runtime_owner.down.sql | 1 + server/migrations/032_runtime_owner.up.sql | 9 ++ server/pkg/db/generated/models.go | 1 + server/pkg/db/generated/runtime.sql.go | 86 +++++++++++++++-- server/pkg/db/queries/runtime.sql | 15 ++- 16 files changed, 361 insertions(+), 23 deletions(-) create mode 100644 apps/web/core/runtimes/mutations.ts create mode 100644 server/migrations/032_runtime_owner.down.sql create mode 100644 server/migrations/032_runtime_owner.up.sql diff --git a/apps/web/core/runtimes/index.ts b/apps/web/core/runtimes/index.ts index 88f0524da0..16cef7928c 100644 --- a/apps/web/core/runtimes/index.ts +++ b/apps/web/core/runtimes/index.ts @@ -1 +1,2 @@ export { runtimeKeys, runtimeListOptions } from "./queries"; +export { useDeleteRuntime } from "./mutations"; diff --git a/apps/web/core/runtimes/mutations.ts b/apps/web/core/runtimes/mutations.ts new file mode 100644 index 0000000000..22980eddf3 --- /dev/null +++ b/apps/web/core/runtimes/mutations.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/shared/api"; +import { runtimeKeys } from "./queries"; + +export function useDeleteRuntime(wsId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (runtimeId: string) => api.deleteRuntime(runtimeId), + onSettled: () => { + qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) }); + }, + }); +} diff --git a/apps/web/core/runtimes/queries.ts b/apps/web/core/runtimes/queries.ts index 3cfb5aa474..48399a01cc 100644 --- a/apps/web/core/runtimes/queries.ts +++ b/apps/web/core/runtimes/queries.ts @@ -4,11 +4,12 @@ import { api } from "@/shared/api"; export const runtimeKeys = { all: (wsId: string) => ["runtimes", wsId] as const, list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const, + listMine: (wsId: string) => [...runtimeKeys.all(wsId), "list", "mine"] as const, }; -export function runtimeListOptions(wsId: string) { +export function runtimeListOptions(wsId: string, owner?: "me") { return queryOptions({ - queryKey: runtimeKeys.list(wsId), - queryFn: () => api.listRuntimes({ workspace_id: wsId }), + queryKey: owner === "me" ? runtimeKeys.listMine(wsId) : runtimeKeys.list(wsId), + queryFn: () => api.listRuntimes({ workspace_id: wsId, owner }), }); } diff --git a/apps/web/features/runtimes/components/runtime-detail.tsx b/apps/web/features/runtimes/components/runtime-detail.tsx index 286a367094..5ed7733087 100644 --- a/apps/web/features/runtimes/components/runtime-detail.tsx +++ b/apps/web/features/runtimes/components/runtime-detail.tsx @@ -1,4 +1,25 @@ +"use client"; + +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { useQuery } from "@tanstack/react-query"; import type { AgentRuntime } from "@/shared/types"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions } from "@core/workspace/queries"; +import { useDeleteRuntime } from "@core/runtimes/mutations"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { formatLastSeen } from "../utils"; import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared"; import { PingSection } from "./ping-section"; @@ -20,6 +41,41 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { const cliVersion = runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null; + const user = useAuthStore((s) => s.user); + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const deleteMutation = useDeleteRuntime(wsId); + + const [deleteOpen, setDeleteOpen] = useState(false); + + // Resolve owner info + const ownerMember = runtime.owner_id + ? members.find((m) => m.user_id === runtime.owner_id) + : null; + const ownerName = ownerMember?.name ?? null; + + // Permission check for delete + const currentMember = user + ? members.find((m) => m.user_id === user.id) + : null; + const isAdmin = currentMember + ? currentMember.role === "owner" || currentMember.role === "admin" + : false; + const isRuntimeOwner = user && runtime.owner_id === user.id; + const canDelete = isAdmin || isRuntimeOwner; + + const handleDelete = () => { + deleteMutation.mutate(runtime.id, { + onSuccess: () => { + toast.success("Runtime deleted"); + setDeleteOpen(false); + }, + onError: (e) => { + toast.error(e instanceof Error ? e.message : "Failed to delete runtime"); + }, + }); + }; + return (
{/* Header */} @@ -36,7 +92,19 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {

{runtime.name}

- +
+ + {canDelete && ( + + )} +
{/* Content */} @@ -50,6 +118,7 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { label="Last Seen" value={formatLastSeen(runtime.last_seen_at)} /> + {ownerName && } {runtime.device_info && ( )} @@ -114,6 +183,28 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { /> + + {/* Delete confirmation */} + { if (!v) setDeleteOpen(false); }}> + + + Delete Runtime + + Are you sure you want to delete “{runtime.name}”? This action cannot be undone. + + + + Cancel + + {deleteMutation.isPending ? "Deleting..." : "Delete"} + + + + ); } diff --git a/apps/web/features/runtimes/components/runtime-list.tsx b/apps/web/features/runtimes/components/runtime-list.tsx index 079524585e..5f2db9279b 100644 --- a/apps/web/features/runtimes/components/runtime-list.tsx +++ b/apps/web/features/runtimes/components/runtime-list.tsx @@ -1,14 +1,21 @@ import { Server } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import type { AgentRuntime } from "@/shared/types"; +import { useWorkspaceId } from "@core/hooks"; +import { memberListOptions } from "@core/workspace/queries"; import { RuntimeModeIcon } from "./shared"; +type RuntimeFilter = "mine" | "all"; + function RuntimeListItem({ runtime, isSelected, + ownerName, onClick, }: { runtime: AgentRuntime; isSelected: boolean; + ownerName: string | null; onClick: () => void; }) { return ( @@ -29,6 +36,7 @@ function RuntimeListItem({
{runtime.name}
{runtime.provider} · {runtime.runtime_mode} + {ownerName && <> · {ownerName}}
void; + filter: RuntimeFilter; + onFilterChange: (filter: RuntimeFilter) => void; }) { + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + + const getOwnerName = (ownerId: string | null) => { + if (!ownerId) return null; + return members.find((m) => m.user_id === ownerId)?.name ?? null; + }; + return (
@@ -58,11 +78,36 @@ export function RuntimeList({ {runtimes.length} online
+ + {/* Filter toggle */} +
+ + +
+ {runtimes.length === 0 ? (

- No runtimes registered + {filter === "mine" ? "No runtimes owned by you" : "No runtimes registered"}

Run{" "} @@ -79,6 +124,7 @@ export function RuntimeList({ key={runtime.id} runtime={runtime} isSelected={runtime.id === selectedId} + ownerName={filter === "all" ? getOwnerName(runtime.owner_id) : null} onClick={() => onSelect(runtime.id)} /> ))} diff --git a/apps/web/features/runtimes/components/runtimes-page.tsx b/apps/web/features/runtimes/components/runtimes-page.tsx index 65f5422239..da3ad1cb75 100644 --- a/apps/web/features/runtimes/components/runtimes-page.tsx +++ b/apps/web/features/runtimes/components/runtimes-page.tsx @@ -17,20 +17,25 @@ import { useWSEvent } from "@/features/realtime"; import { RuntimeList } from "./runtime-list"; import { RuntimeDetail } from "./runtime-detail"; +type RuntimeFilter = "mine" | "all"; + export default function RuntimesPage() { const isLoading = useAuthStore((s) => s.isLoading); const wsId = useWorkspaceId(); const qc = useQueryClient(); - const { data: runtimes = [], isLoading: fetching } = useQuery(runtimeListOptions(wsId)); + const [filter, setFilter] = useState("mine"); const [selectedId, setSelectedId] = useState(""); + const ownerParam = filter === "mine" ? "me" as const : undefined; + const { data: runtimes = [], isLoading: fetching } = useQuery(runtimeListOptions(wsId, ownerParam)); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_runtimes_layout", }); // Re-fetch on daemon register/deregister events. const handleDaemonEvent = useCallback(() => { - qc.invalidateQueries({ queryKey: runtimeKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) }); }, [qc, wsId]); useWSEvent("daemon:register", handleDaemonEvent); @@ -95,6 +100,8 @@ export default function RuntimesPage() { runtimes={runtimes} selectedId={effectiveSelectedId} onSelect={setSelectedId} + filter={filter} + onFilterChange={setFilter} /> diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index e8e6b004d7..cd70daca34 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -347,13 +347,18 @@ export class ApiClient { return this.fetch(`/api/agents/${id}/restore`, { method: "POST" }); } - async listRuntimes(params?: { workspace_id?: string }): Promise { + async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise { const search = new URLSearchParams(); const wsId = params?.workspace_id ?? this.workspaceId; if (wsId) search.set("workspace_id", wsId); + if (params?.owner) search.set("owner", params.owner); return this.fetch(`/api/runtimes?${search}`); } + async deleteRuntime(runtimeId: string): Promise { + await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" }); + } + async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise { const search = new URLSearchParams(); if (params?.days) search.set("days", String(params.days)); diff --git a/apps/web/shared/types/agent.ts b/apps/web/shared/types/agent.ts index 3ac2a17cc7..25af8e0cde 100644 --- a/apps/web/shared/types/agent.ts +++ b/apps/web/shared/types/agent.ts @@ -14,6 +14,7 @@ export interface RuntimeDevice { status: "online" | "offline"; device_info: string; metadata: Record; + owner_id: string | null; last_seen_at: string | null; created_at: string; updated_at: string; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 3fa7c240e3..0eb897fe20 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -233,12 +233,15 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // Runtimes r.Route("/api/runtimes", func(r chi.Router) { r.Get("/", h.ListAgentRuntimes) - r.Get("/{runtimeId}/usage", h.GetRuntimeUsage) - r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity) - r.Post("/{runtimeId}/ping", h.InitiatePing) - r.Get("/{runtimeId}/ping/{pingId}", h.GetPing) - r.Post("/{runtimeId}/update", h.InitiateUpdate) - r.Get("/{runtimeId}/update/{updateId}", h.GetUpdate) + r.Route("/{runtimeId}", func(r chi.Router) { + r.Get("/usage", h.GetRuntimeUsage) + r.Get("/activity", h.GetRuntimeTaskActivity) + r.Post("/ping", h.InitiatePing) + r.Get("/ping/{pingId}", h.GetPing) + r.Post("/update", h.InitiateUpdate) + r.Get("/update/{updateId}", h.GetUpdate) + r.Delete("/", h.DeleteAgentRuntime) + }) }) // Inbox diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 6c88100b6f..b53471f0f8 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -57,7 +57,8 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { } // Verify the caller is a member of the target workspace. - if _, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found"); !ok { + member, ok := h.requireWorkspaceMember(w, r, req.WorkspaceID, "workspace not found") + if !ok { return } @@ -104,6 +105,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { Status: status, DeviceInfo: deviceInfo, Metadata: metadata, + OwnerID: member.UserID, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to register runtime: "+err.Error()) diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index d85e4494b6..2cfc44cd31 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "log/slog" "net/http" "strconv" "time" @@ -9,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/protocol" ) type AgentRuntimeResponse struct { @@ -21,6 +23,7 @@ type AgentRuntimeResponse struct { Status string `json:"status"` DeviceInfo string `json:"device_info"` Metadata any `json:"metadata"` + OwnerID *string `json:"owner_id"` LastSeenAt *string `json:"last_seen_at"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` @@ -45,6 +48,7 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse { Status: rt.Status, DeviceInfo: rt.DeviceInfo, Metadata: metadata, + OwnerID: uuidToPtr(rt.OwnerID), LastSeenAt: timestampToPtr(rt.LastSeenAt), CreatedAt: timestampToString(rt.CreatedAt), UpdatedAt: timestampToString(rt.UpdatedAt), @@ -285,7 +289,22 @@ func parseSinceParam(r *http.Request, defaultDays int) pgtype.Timestamptz { func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) { workspaceID := resolveWorkspaceID(r) - runtimes, err := h.Queries.ListAgentRuntimes(r.Context(), parseUUID(workspaceID)) + var runtimes []db.AgentRuntime + var err error + + if ownerFilter := r.URL.Query().Get("owner"); ownerFilter == "me" { + userID, ok := requireUserID(w, r) + if !ok { + return + } + runtimes, err = h.Queries.ListAgentRuntimesByOwner(r.Context(), db.ListAgentRuntimesByOwnerParams{ + WorkspaceID: parseUUID(workspaceID), + OwnerID: parseUUID(userID), + }) + } else { + runtimes, err = h.Queries.ListAgentRuntimes(r.Context(), parseUUID(workspaceID)) + } + if err != nil { writeError(w, http.StatusInternalServerError, "failed to list runtimes") return @@ -298,3 +317,54 @@ func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } + +// DeleteAgentRuntime deletes a runtime after permission and dependency checks. +func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) { + runtimeID := chi.URLParam(r, "runtimeId") + + rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + if err != nil { + writeError(w, http.StatusNotFound, "runtime not found") + return + } + + wsID := uuidToString(rt.WorkspaceID) + member, ok := h.requireWorkspaceMember(w, r, wsID, "runtime not found") + if !ok { + return + } + + // Permission: owner/admin can delete any runtime; members can only delete their own. + userID := uuidToString(member.UserID) + isAdmin := roleAllowed(member.Role, "owner", "admin") + isOwner := rt.OwnerID.Valid && uuidToString(rt.OwnerID) == userID + if !isAdmin && !isOwner { + writeError(w, http.StatusForbidden, "you can only delete your own runtimes") + return + } + + // Check if any agents are bound to this runtime (ON DELETE RESTRICT). + agentCount, err := h.Queries.CountAgentsByRuntime(r.Context(), rt.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to check runtime dependencies") + return + } + if agentCount > 0 { + writeError(w, http.StatusConflict, "cannot delete runtime: it has agents bound to it. Reassign or remove the agents first.") + return + } + + if err := h.Queries.DeleteAgentRuntime(r.Context(), rt.ID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete runtime") + return + } + + slog.Info("runtime deleted", "runtime_id", runtimeID, "deleted_by", userID) + + // Notify frontend to refresh runtime list. + h.publish(protocol.EventDaemonRegister, wsID, "member", userID, map[string]any{ + "action": "delete", + }) + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/server/migrations/032_runtime_owner.down.sql b/server/migrations/032_runtime_owner.down.sql new file mode 100644 index 0000000000..090d1ff759 --- /dev/null +++ b/server/migrations/032_runtime_owner.down.sql @@ -0,0 +1 @@ +ALTER TABLE agent_runtime DROP COLUMN IF EXISTS owner_id; diff --git a/server/migrations/032_runtime_owner.up.sql b/server/migrations/032_runtime_owner.up.sql new file mode 100644 index 0000000000..5a2c6c5616 --- /dev/null +++ b/server/migrations/032_runtime_owner.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE agent_runtime ADD COLUMN owner_id UUID REFERENCES "user"(id); + +-- Backfill: set existing runtimes' owner to the workspace owner +UPDATE agent_runtime ar +SET owner_id = ( + SELECT m.user_id FROM member m + WHERE m.workspace_id = ar.workspace_id AND m.role = 'owner' + LIMIT 1 +); diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 35b37e8796..f8604aa9df 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -52,6 +52,7 @@ type AgentRuntime struct { LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + OwnerID pgtype.UUID `json:"owner_id"` } type AgentSkill struct { diff --git a/server/pkg/db/generated/runtime.sql.go b/server/pkg/db/generated/runtime.sql.go index 162520aea4..e605fc1ecb 100644 --- a/server/pkg/db/generated/runtime.sql.go +++ b/server/pkg/db/generated/runtime.sql.go @@ -11,6 +11,26 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countAgentsByRuntime = `-- name: CountAgentsByRuntime :one +SELECT count(*) FROM agent WHERE runtime_id = $1 +` + +func (q *Queries) CountAgentsByRuntime(ctx context.Context, runtimeID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countAgentsByRuntime, runtimeID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteAgentRuntime = `-- name: DeleteAgentRuntime :exec +DELETE FROM agent_runtime WHERE id = $1 +` + +func (q *Queries) DeleteAgentRuntime(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteAgentRuntime, id) + return err +} + const failTasksForOfflineRuntimes = `-- name: FailTasksForOfflineRuntimes :many UPDATE agent_task_queue SET status = 'failed', completed_at = now(), error = 'runtime went offline' @@ -50,7 +70,7 @@ func (q *Queries) FailTasksForOfflineRuntimes(ctx context.Context) ([]FailTasksF } const getAgentRuntime = `-- name: GetAgentRuntime :one -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id FROM agent_runtime WHERE id = $1 ` @@ -70,12 +90,13 @@ func (q *Queries) GetAgentRuntime(ctx context.Context, id pgtype.UUID) (AgentRun &i.LastSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.OwnerID, ) return i, err } const getAgentRuntimeForWorkspace = `-- name: GetAgentRuntimeForWorkspace :one -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id FROM agent_runtime WHERE id = $1 AND workspace_id = $2 ` @@ -100,12 +121,13 @@ func (q *Queries) GetAgentRuntimeForWorkspace(ctx context.Context, arg GetAgentR &i.LastSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.OwnerID, ) return i, err } const listAgentRuntimes = `-- name: ListAgentRuntimes :many -SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at FROM agent_runtime +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id FROM agent_runtime WHERE workspace_id = $1 ORDER BY created_at ASC ` @@ -132,6 +154,52 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID &i.LastSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.OwnerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAgentRuntimesByOwner = `-- name: ListAgentRuntimesByOwner :many +SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id FROM agent_runtime +WHERE workspace_id = $1 AND owner_id = $2 +ORDER BY created_at ASC +` + +type ListAgentRuntimesByOwnerParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` +} + +func (q *Queries) ListAgentRuntimesByOwner(ctx context.Context, arg ListAgentRuntimesByOwnerParams) ([]AgentRuntime, error) { + rows, err := q.db.Query(ctx, listAgentRuntimesByOwner, arg.WorkspaceID, arg.OwnerID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AgentRuntime{} + for rows.Next() { + var i AgentRuntime + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.DaemonID, + &i.Name, + &i.RuntimeMode, + &i.Provider, + &i.Status, + &i.DeviceInfo, + &i.Metadata, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, ); err != nil { return nil, err } @@ -191,7 +259,7 @@ const updateAgentRuntimeHeartbeat = `-- name: UpdateAgentRuntimeHeartbeat :one UPDATE agent_runtime SET status = 'online', last_seen_at = now(), updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at +RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id ` func (q *Queries) UpdateAgentRuntimeHeartbeat(ctx context.Context, id pgtype.UUID) (AgentRuntime, error) { @@ -210,6 +278,7 @@ func (q *Queries) UpdateAgentRuntimeHeartbeat(ctx context.Context, id pgtype.UUI &i.LastSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.OwnerID, ) return i, err } @@ -224,8 +293,9 @@ INSERT INTO agent_runtime ( status, device_info, metadata, + owner_id, last_seen_at -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) ON CONFLICT (workspace_id, daemon_id, provider) DO UPDATE SET name = EXCLUDED.name, @@ -233,9 +303,10 @@ DO UPDATE SET status = EXCLUDED.status, device_info = EXCLUDED.device_info, metadata = EXCLUDED.metadata, + owner_id = COALESCE(EXCLUDED.owner_id, agent_runtime.owner_id), last_seen_at = now(), updated_at = now() -RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at +RETURNING id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at, owner_id ` type UpsertAgentRuntimeParams struct { @@ -247,6 +318,7 @@ type UpsertAgentRuntimeParams struct { Status string `json:"status"` DeviceInfo string `json:"device_info"` Metadata []byte `json:"metadata"` + OwnerID pgtype.UUID `json:"owner_id"` } func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntimeParams) (AgentRuntime, error) { @@ -259,6 +331,7 @@ func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntime arg.Status, arg.DeviceInfo, arg.Metadata, + arg.OwnerID, ) var i AgentRuntime err := row.Scan( @@ -274,6 +347,7 @@ func (q *Queries) UpsertAgentRuntime(ctx context.Context, arg UpsertAgentRuntime &i.LastSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.OwnerID, ) return i, err } diff --git a/server/pkg/db/queries/runtime.sql b/server/pkg/db/queries/runtime.sql index 415fa63cce..1b80eb354d 100644 --- a/server/pkg/db/queries/runtime.sql +++ b/server/pkg/db/queries/runtime.sql @@ -21,8 +21,9 @@ INSERT INTO agent_runtime ( status, device_info, metadata, + owner_id, last_seen_at -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) ON CONFLICT (workspace_id, daemon_id, provider) DO UPDATE SET name = EXCLUDED.name, @@ -30,6 +31,7 @@ DO UPDATE SET status = EXCLUDED.status, device_info = EXCLUDED.device_info, metadata = EXCLUDED.metadata, + owner_id = COALESCE(EXCLUDED.owner_id, agent_runtime.owner_id), last_seen_at = now(), updated_at = now() RETURNING *; @@ -62,3 +64,14 @@ WHERE status IN ('dispatched', 'running') SELECT id FROM agent_runtime WHERE status = 'offline' ) RETURNING id, agent_id, issue_id; + +-- name: ListAgentRuntimesByOwner :many +SELECT * FROM agent_runtime +WHERE workspace_id = $1 AND owner_id = $2 +ORDER BY created_at ASC; + +-- name: DeleteAgentRuntime :exec +DELETE FROM agent_runtime WHERE id = $1; + +-- name: CountAgentsByRuntime :one +SELECT count(*) FROM agent WHERE runtime_id = $1;