mirror of
https://github.com/hanzoai/agents.git
synced 2026-10-30 18:48:36 +00:00
Move the control-plane's REST/UI surface off gin's router and onto
github.com/zap-proto/zip v1.10.0 (over zap-proto/http v0.3.0). zip now owns
routing, the middleware chain and the transport; all 130 routes are registered
natively via App.Get/Post/Put/Patch/Delete and App.Group.
The route table is unchanged: 130 method+path pairs before and after, verified
by diff. The only spelling change is the wildcard leaf, "/ui/*filepath" ->
"/ui/*", which matches the same paths.
Handlers are fronted rather than rewritten. ~24k lines of gin handler bodies
are this service's public API and a hand port of that volume cannot be proven
equivalent, so internal/server/ginbridge.go provides the one seam:
ginHandler(h) wraps a gin.HandlerFunc, copying the matched zip route's
params onto the gin context so c.Param(...) still works.
ginChain(mws...) runs the gin middleware stack (CORS, access log, timeout,
API-key auth) unmodified, so CORS origin echoing and the
auth abort bodies stay byte-for-byte identical.
The net/http adaptor underneath supports buffered, flushed and hijacked
responses, so the SSE and WebSocket routes keep streaming through the same
seam rather than being rewritten badly.
Two things that would otherwise silently break the API:
- Start listens on "http://:PORT". A bare address binds zip's default ZAP
transport, which would change the wire protocol of the REST surface.
- The NoRoute fallback is All("/+"), not All("/*"). "/*" also matches "/"
and shadowed the root redirect, turning it into a 404.
Coverage: routes_test.go pins the exact 130-route table and the NoRoute
semantics; ginbridge_test.go pins params, status, body, CORS echo, auth abort
and SSE through the bridge. Both pass under the default and "embedded" build
tags. A package TestMain sets gin's mode once, removing the pre-existing
gin.SetMode data race between parallel tests in this package.
Co-authored-by: Hanzo Dev <[email protected]>
560 lines
16 KiB
Go
560 lines
16 KiB
Go
package ui
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/hanzoai/agents/control-plane/internal/logger"
|
|
"github.com/hanzoai/agents/control-plane/internal/storage"
|
|
"github.com/hanzoai/agents/control-plane/pkg/types"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// IdentityHandlers handles identity and credential UI endpoints
|
|
type IdentityHandlers struct {
|
|
storage storage.StorageProvider
|
|
}
|
|
|
|
// NewIdentityHandlers creates a new identity handlers instance
|
|
func NewIdentityHandlers(storage storage.StorageProvider) *IdentityHandlers {
|
|
return &IdentityHandlers{
|
|
storage: storage,
|
|
}
|
|
}
|
|
|
|
// DIDSearchResult represents a search result for DIDs
|
|
type DIDSearchResult struct {
|
|
Type string `json:"type"` // "agent", "reasoner", "skill"
|
|
DID string `json:"did"`
|
|
Name string `json:"name"`
|
|
ParentDID string `json:"parent_did,omitempty"`
|
|
ParentName string `json:"parent_name,omitempty"`
|
|
DerivationPath string `json:"derivation_path"`
|
|
Status string `json:"status,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// DIDStatsResponse represents DID statistics
|
|
type DIDStatsResponse struct {
|
|
TotalAgents int `json:"total_agents"`
|
|
TotalReasoners int `json:"total_reasoners"`
|
|
TotalSkills int `json:"total_skills"`
|
|
TotalDIDs int `json:"total_dids"`
|
|
}
|
|
|
|
// AgentDIDResponse represents an agent with its DIDs
|
|
type AgentDIDResponse struct {
|
|
DID string `json:"did"`
|
|
AgentNodeID string `json:"agent_node_id"`
|
|
Status string `json:"status"`
|
|
DerivationPath string `json:"derivation_path"`
|
|
CreatedAt string `json:"created_at"`
|
|
ReasonerCount int `json:"reasoner_count"`
|
|
SkillCount int `json:"skill_count"`
|
|
Reasoners []ComponentDIDInfo `json:"reasoners,omitempty"`
|
|
Skills []ComponentDIDInfo `json:"skills,omitempty"`
|
|
}
|
|
|
|
// ComponentDIDInfo represents a reasoner or skill DID
|
|
type ComponentDIDInfo struct {
|
|
DID string `json:"did"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"` // "reasoner" or "skill"
|
|
DerivationPath string `json:"derivation_path"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// VCSearchResult represents a verifiable credential search result
|
|
type VCSearchResult struct {
|
|
VCID string `json:"vc_id"`
|
|
ExecutionID string `json:"execution_id"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
WorkflowName string `json:"workflow_name,omitempty"`
|
|
SessionID string `json:"session_id"`
|
|
IssuerDID string `json:"issuer_did"`
|
|
TargetDID string `json:"target_did"`
|
|
CallerDID string `json:"caller_did"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
DurationMS *int `json:"duration_ms,omitempty"`
|
|
ReasonerName string `json:"reasoner_name,omitempty"`
|
|
AgentName string `json:"agent_name,omitempty"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
|
|
// GetDIDStats returns statistics about DIDs in the system
|
|
// GET /v1/ui/identity/dids/stats
|
|
func (h *IdentityHandlers) GetDIDStats(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
// Get all agent DIDs
|
|
agentDIDs, err := h.storage.ListAgentDIDs(ctx)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Msg("Failed to list agent DIDs")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get DID stats"})
|
|
return
|
|
}
|
|
|
|
// Get all component DIDs (pass empty string to get all)
|
|
componentDIDs, err := h.storage.ListComponentDIDs(ctx, "")
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Msg("Failed to list component DIDs")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get DID stats"})
|
|
return
|
|
}
|
|
|
|
// Count reasoners and skills
|
|
reasonerCount := 0
|
|
skillCount := 0
|
|
for i := range componentDIDs {
|
|
comp := componentDIDs[i]
|
|
if comp.ComponentType == "reasoner" {
|
|
reasonerCount++
|
|
} else if comp.ComponentType == "skill" {
|
|
skillCount++
|
|
}
|
|
}
|
|
|
|
stats := DIDStatsResponse{
|
|
TotalAgents: len(agentDIDs),
|
|
TotalReasoners: reasonerCount,
|
|
TotalSkills: skillCount,
|
|
TotalDIDs: len(agentDIDs) + len(componentDIDs),
|
|
}
|
|
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|
|
|
|
// SearchDIDs searches for DIDs by query string
|
|
// GET /v1/ui/identity/dids/search?q=greeting&type=all&limit=20&offset=0
|
|
func (h *IdentityHandlers) SearchDIDs(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
query := strings.ToLower(c.Query("q"))
|
|
didType := c.DefaultQuery("type", "all") // "all", "agent", "reasoner", "skill"
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
|
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
var results []DIDSearchResult
|
|
|
|
// Search agents if type is "all" or "agent"
|
|
if didType == "all" || didType == "agent" {
|
|
agentDIDs, err := h.storage.ListAgentDIDs(ctx)
|
|
if err == nil {
|
|
for i := range agentDIDs {
|
|
agent := agentDIDs[i]
|
|
if query == "" || strings.Contains(strings.ToLower(agent.AgentNodeID), query) {
|
|
results = append(results, DIDSearchResult{
|
|
Type: "agent",
|
|
DID: agent.DID,
|
|
Name: agent.AgentNodeID,
|
|
DerivationPath: agent.DerivationPath,
|
|
Status: string(agent.Status),
|
|
CreatedAt: agent.RegisteredAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Search components if type is "all", "reasoner", or "skill"
|
|
if didType == "all" || didType == "reasoner" || didType == "skill" {
|
|
componentDIDs, err := h.storage.ListComponentDIDs(ctx, "")
|
|
if err == nil {
|
|
for i := range componentDIDs {
|
|
comp := componentDIDs[i]
|
|
// Filter by type
|
|
if didType != "all" && comp.ComponentType != didType {
|
|
continue
|
|
}
|
|
|
|
// Filter by query
|
|
if query != "" && !strings.Contains(strings.ToLower(comp.ComponentName), query) {
|
|
continue
|
|
}
|
|
|
|
results = append(results, DIDSearchResult{
|
|
Type: comp.ComponentType,
|
|
DID: comp.ComponentDID,
|
|
Name: comp.ComponentName,
|
|
ParentDID: comp.AgentDID,
|
|
DerivationPath: strconv.Itoa(comp.DerivationIndex),
|
|
CreatedAt: comp.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply pagination
|
|
total := len(results)
|
|
start := offset
|
|
end := offset + limit
|
|
|
|
if start > total {
|
|
start = total
|
|
}
|
|
if end > total {
|
|
end = total
|
|
}
|
|
|
|
paginatedResults := results[start:end]
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"results": paginatedResults,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"has_more": end < total,
|
|
})
|
|
}
|
|
|
|
// ListAgents returns a paginated list of agent DIDs
|
|
// GET /v1/ui/identity/agents?limit=10&offset=0
|
|
func (h *IdentityHandlers) ListAgents(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
|
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
|
|
agentDIDs, err := h.storage.ListAgentDIDs(ctx)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Msg("Failed to list agent DIDs")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list agents"})
|
|
return
|
|
}
|
|
|
|
// Get component counts for each agent
|
|
componentDIDs, _ := h.storage.ListComponentDIDs(ctx, "")
|
|
componentsByAgent := make(map[string][]*types.ComponentDIDInfo)
|
|
for i := range componentDIDs {
|
|
comp := componentDIDs[i]
|
|
componentsByAgent[comp.AgentDID] = append(componentsByAgent[comp.AgentDID], comp)
|
|
}
|
|
|
|
// Build response
|
|
var agents []AgentDIDResponse
|
|
for i := range agentDIDs {
|
|
agent := agentDIDs[i]
|
|
components := componentsByAgent[agent.DID]
|
|
reasonerCount := 0
|
|
skillCount := 0
|
|
for _, comp := range components {
|
|
if comp.ComponentType == "reasoner" {
|
|
reasonerCount++
|
|
} else if comp.ComponentType == "skill" {
|
|
skillCount++
|
|
}
|
|
}
|
|
|
|
agents = append(agents, AgentDIDResponse{
|
|
DID: agent.DID,
|
|
AgentNodeID: agent.AgentNodeID,
|
|
Status: string(agent.Status),
|
|
DerivationPath: agent.DerivationPath,
|
|
CreatedAt: agent.RegisteredAt.Format("2006-01-02T15:04:05Z"),
|
|
ReasonerCount: reasonerCount,
|
|
SkillCount: skillCount,
|
|
})
|
|
}
|
|
|
|
// Apply pagination
|
|
total := len(agents)
|
|
start := offset
|
|
end := offset + limit
|
|
|
|
if start > total {
|
|
start = total
|
|
}
|
|
if end > total {
|
|
end = total
|
|
}
|
|
|
|
paginatedAgents := agents[start:end]
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"agents": paginatedAgents,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"has_more": end < total,
|
|
})
|
|
}
|
|
|
|
// GetAgentDetails returns detailed information about an agent and its components
|
|
// GET /v1/ui/identity/agents/:agent_id/details?limit=20&offset=0
|
|
func (h *IdentityHandlers) GetAgentDetails(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
agentNodeID := c.Param("agent_id")
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
|
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
// Find the agent DID
|
|
agentDIDs, err := h.storage.ListAgentDIDs(ctx)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Msg("Failed to list agent DIDs")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agent details"})
|
|
return
|
|
}
|
|
|
|
var agentDID *types.AgentDIDInfo
|
|
for i := range agentDIDs {
|
|
if agentDIDs[i].AgentNodeID == agentNodeID {
|
|
agentDID = agentDIDs[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if agentDID == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
|
|
return
|
|
}
|
|
|
|
// Get components for this agent
|
|
componentDIDs, err := h.storage.ListComponentDIDs(ctx, agentDID.DID)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Msg("Failed to list component DIDs")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agent components"})
|
|
return
|
|
}
|
|
|
|
var reasoners []ComponentDIDInfo
|
|
var skills []ComponentDIDInfo
|
|
|
|
for i := range componentDIDs {
|
|
comp := componentDIDs[i]
|
|
|
|
info := ComponentDIDInfo{
|
|
DID: comp.ComponentDID,
|
|
Name: comp.ComponentName,
|
|
Type: comp.ComponentType,
|
|
DerivationPath: strconv.Itoa(comp.DerivationIndex),
|
|
CreatedAt: comp.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
|
|
if comp.ComponentType == "reasoner" {
|
|
reasoners = append(reasoners, info)
|
|
} else if comp.ComponentType == "skill" {
|
|
skills = append(skills, info)
|
|
}
|
|
}
|
|
|
|
// Apply pagination to reasoners
|
|
totalReasoners := len(reasoners)
|
|
start := offset
|
|
end := offset + limit
|
|
|
|
if start > totalReasoners {
|
|
start = totalReasoners
|
|
}
|
|
if end > totalReasoners {
|
|
end = totalReasoners
|
|
}
|
|
|
|
paginatedReasoners := reasoners[start:end]
|
|
|
|
response := AgentDIDResponse{
|
|
DID: agentDID.DID,
|
|
AgentNodeID: agentDID.AgentNodeID,
|
|
Status: string(agentDID.Status),
|
|
DerivationPath: agentDID.DerivationPath,
|
|
CreatedAt: agentDID.RegisteredAt.Format("2006-01-02T15:04:05Z"),
|
|
ReasonerCount: len(reasoners),
|
|
SkillCount: len(skills),
|
|
Reasoners: paginatedReasoners,
|
|
Skills: skills,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"agent": response,
|
|
"total_reasoners": totalReasoners,
|
|
"reasoners_limit": limit,
|
|
"reasoners_offset": offset,
|
|
"reasoners_has_more": end < totalReasoners,
|
|
})
|
|
}
|
|
|
|
// SearchCredentials searches for verifiable credentials with time-range filtering
|
|
// GET /v1/ui/identity/credentials/search?start_time=...&end_time=...&workflow_id=...&status=...&limit=50&offset=0
|
|
func (h *IdentityHandlers) SearchCredentials(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
// Parse filters
|
|
filters := types.VCFilters{
|
|
Limit: 50,
|
|
Offset: 0,
|
|
}
|
|
|
|
if limit := c.Query("limit"); limit != "" {
|
|
if l, err := strconv.Atoi(limit); err == nil && l > 0 {
|
|
if l > 100 {
|
|
l = 100
|
|
}
|
|
filters.Limit = l
|
|
}
|
|
}
|
|
|
|
if offset := c.Query("offset"); offset != "" {
|
|
if o, err := strconv.Atoi(offset); err == nil && o >= 0 {
|
|
filters.Offset = o
|
|
}
|
|
}
|
|
|
|
if workflowID := c.Query("workflow_id"); workflowID != "" {
|
|
filters.WorkflowID = &workflowID
|
|
}
|
|
|
|
if sessionID := c.Query("session_id"); sessionID != "" {
|
|
filters.SessionID = &sessionID
|
|
}
|
|
|
|
if statusParam := c.Query("status"); statusParam != "" {
|
|
normalized := strings.ToLower(statusParam)
|
|
switch normalized {
|
|
case "all":
|
|
normalized = ""
|
|
case "verified":
|
|
normalized = "completed"
|
|
case "failed":
|
|
normalized = "failed"
|
|
case "pending":
|
|
normalized = "pending"
|
|
case "revoked":
|
|
normalized = "revoked"
|
|
}
|
|
if normalized != "" {
|
|
filters.Status = &normalized
|
|
}
|
|
}
|
|
|
|
if issuerDID := c.Query("issuer_did"); issuerDID != "" {
|
|
filters.IssuerDID = &issuerDID
|
|
}
|
|
|
|
if executionID := c.Query("execution_id"); executionID != "" {
|
|
filters.ExecutionID = &executionID
|
|
}
|
|
|
|
if callerDID := c.Query("caller_did"); callerDID != "" {
|
|
filters.CallerDID = &callerDID
|
|
}
|
|
|
|
if targetDID := c.Query("target_did"); targetDID != "" {
|
|
filters.TargetDID = &targetDID
|
|
}
|
|
|
|
if agentNodeID := c.Query("agent_node_id"); agentNodeID != "" {
|
|
filters.AgentNodeID = &agentNodeID
|
|
} else if agentNodeID := c.Query("agent_id"); agentNodeID != "" {
|
|
filters.AgentNodeID = &agentNodeID
|
|
}
|
|
|
|
if search := strings.TrimSpace(c.Query("query")); search != "" {
|
|
filters.Search = &search
|
|
} else if q := strings.TrimSpace(c.Query("q")); q != "" {
|
|
filters.Search = &q
|
|
}
|
|
|
|
// Parse time range filters
|
|
if startTime := c.Query("start_time"); startTime != "" {
|
|
if t, err := time.Parse(time.RFC3339, startTime); err == nil {
|
|
filters.CreatedAfter = &t
|
|
} else {
|
|
logger.Logger.Warn().Str("start_time", startTime).Err(err).Msg("Failed to parse start_time")
|
|
}
|
|
}
|
|
|
|
if endTime := c.Query("end_time"); endTime != "" {
|
|
if t, err := time.Parse(time.RFC3339, endTime); err == nil {
|
|
filters.CreatedBefore = &t
|
|
} else {
|
|
logger.Logger.Warn().Str("end_time", endTime).Err(err).Msg("Failed to parse end_time")
|
|
}
|
|
}
|
|
|
|
// Query execution VCs
|
|
vcs, err := h.storage.ListExecutionVCs(ctx, filters)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Msg("Failed to query execution VCs")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search credentials"})
|
|
return
|
|
}
|
|
|
|
countFilters := filters
|
|
countFilters.Limit = 0
|
|
countFilters.Offset = 0
|
|
totalCount, err := h.storage.CountExecutionVCs(ctx, countFilters)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Msg("Failed to count execution VCs")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search credentials"})
|
|
return
|
|
}
|
|
|
|
// Transform to search results
|
|
var results []VCSearchResult
|
|
for i := range vcs {
|
|
vc := vcs[i]
|
|
var agentName string
|
|
if vc.AgentNodeID != nil {
|
|
agentName = *vc.AgentNodeID
|
|
}
|
|
|
|
var workflowName string
|
|
if vc.WorkflowName != nil {
|
|
workflowName = *vc.WorkflowName
|
|
}
|
|
|
|
verified := strings.EqualFold(vc.Status, "completed") || strings.EqualFold(vc.Status, "succeeded")
|
|
|
|
results = append(results, VCSearchResult{
|
|
VCID: vc.VCID,
|
|
ExecutionID: vc.ExecutionID,
|
|
WorkflowID: vc.WorkflowID,
|
|
WorkflowName: workflowName,
|
|
SessionID: vc.SessionID,
|
|
IssuerDID: vc.IssuerDID,
|
|
TargetDID: vc.TargetDID,
|
|
CallerDID: vc.CallerDID,
|
|
Status: vc.Status,
|
|
CreatedAt: vc.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
AgentName: agentName,
|
|
Verified: verified,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"credentials": results,
|
|
"total": totalCount,
|
|
"limit": filters.Limit,
|
|
"offset": filters.Offset,
|
|
"has_more": filters.Offset+len(results) < totalCount,
|
|
})
|
|
}
|
|
|
|
// RegisterRoutes registers all identity UI routes
|
|
func (h *IdentityHandlers) RegisterRoutes(router zip.Router, bridge func(gin.HandlerFunc) zip.Handler) {
|
|
identity := router.Group("/identity")
|
|
{
|
|
// DID Explorer endpoints
|
|
identity.Get("/dids/stats", bridge(h.GetDIDStats))
|
|
identity.Get("/dids/search", bridge(h.SearchDIDs))
|
|
identity.Get("/agents", bridge(h.ListAgents))
|
|
identity.Get("/agents/:agent_id/details", bridge(h.GetAgentDetails))
|
|
|
|
// Credentials endpoints
|
|
identity.Get("/credentials/search", bridge(h.SearchCredentials))
|
|
}
|
|
}
|