@@ -58,11 +78,36 @@ export function RuntimeList({
{runtimes.length} online
- 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;