Skip to content

Commit d6e5b0d

Browse files
Merge pull request #11 from Agent-Field/santosh/VC-UI
Add workflow VC status batch endpoint
2 parents 75a7936 + b42241d commit d6e5b0d

16 files changed

Lines changed: 779 additions & 226 deletions

File tree

control-plane/internal/handlers/ui/did.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,48 @@ func (h *DIDHandler) GetExecutionVCHandler(c *gin.Context) {
316316
c.JSON(http.StatusOK, executionVC)
317317
}
318318

319+
type workflowVCStatusBatchRequest struct {
320+
WorkflowIDs []string `json:"workflow_ids"`
321+
}
322+
323+
// GetWorkflowVCStatusBatchHandler returns VC status summaries for multiple workflows.
324+
// POST /api/ui/v1/workflows/vc-status
325+
func (h *DIDHandler) GetWorkflowVCStatusBatchHandler(c *gin.Context) {
326+
var req workflowVCStatusBatchRequest
327+
if err := c.ShouldBindJSON(&req); err != nil || len(req.WorkflowIDs) == 0 {
328+
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_ids is required"})
329+
return
330+
}
331+
332+
result := make([]types.WorkflowVCStatusSummary, 0, len(req.WorkflowIDs))
333+
334+
if h.vcService == nil {
335+
for _, id := range req.WorkflowIDs {
336+
result = append(result, *types.DefaultWorkflowVCStatusSummary(id))
337+
}
338+
c.JSON(http.StatusOK, gin.H{"summaries": result})
339+
return
340+
}
341+
342+
summaryMap, err := h.vcService.GetWorkflowVCStatusSummaries(req.WorkflowIDs)
343+
if err != nil {
344+
c.JSON(http.StatusInternalServerError, gin.H{
345+
"error": fmt.Sprintf("failed to fetch workflow VC statuses: %v", err),
346+
})
347+
return
348+
}
349+
350+
for _, id := range req.WorkflowIDs {
351+
summary, ok := summaryMap[id]
352+
if !ok || summary == nil {
353+
summary = types.DefaultWorkflowVCStatusSummary(id)
354+
}
355+
result = append(result, *summary)
356+
}
357+
358+
c.JSON(http.StatusOK, gin.H{"summaries": result})
359+
}
360+
319361
// GetWorkflowVCChainHandler handles requests for workflow VC chain information.
320362
// GET /api/ui/v1/workflows/:workflowId/vc-chain
321363
func (h *DIDHandler) GetWorkflowVCChainHandler(c *gin.Context) {

control-plane/internal/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,7 @@ func (s *AgentFieldServer) setupRoutes() {
772772
{
773773
workflows.GET("/:workflowId/dag", handlers.GetWorkflowDAGHandler(s.storage))
774774
didHandler := ui.NewDIDHandler(s.storage, s.didService, s.vcService)
775+
workflows.POST("/vc-status", didHandler.GetWorkflowVCStatusBatchHandler)
775776
workflows.GET("/:workflowId/vc-chain", didHandler.GetWorkflowVCChainHandler)
776777
workflows.POST("/:workflowId/verify-vc", didHandler.VerifyWorkflowVCComprehensiveHandler)
777778
}

control-plane/internal/server/server_routes_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ func (s *stubStorage) GetExecutionVC(ctx context.Context, vcID string) (*types.E
316316
func (s *stubStorage) ListExecutionVCs(ctx context.Context, filters types.VCFilters) ([]*types.ExecutionVCInfo, error) {
317317
return nil, nil
318318
}
319+
func (s *stubStorage) ListWorkflowVCStatusSummaries(ctx context.Context, workflowIDs []string) ([]*types.WorkflowVCStatusAggregation, error) {
320+
return nil, nil
321+
}
319322

320323
// Workflow VC operations
321324
func (s *stubStorage) StoreWorkflowVC(ctx context.Context, workflowVCID, workflowID, sessionID string, componentVCIDs []string, status string, startTime, endTime *time.Time, totalSteps, completedSteps int, storageURI string, documentSizeBytes int64) error {

control-plane/internal/services/vc_service.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,71 @@ func (s *VCService) ShouldPersistExecutionVC() bool {
6969
return s.config.VCRequirements.PersistExecutionVC
7070
}
7171

72+
// GetWorkflowVCStatusSummaries returns lightweight VC status summaries for the provided workflows.
73+
func (s *VCService) GetWorkflowVCStatusSummaries(workflowIDs []string) (map[string]*types.WorkflowVCStatusSummary, error) {
74+
summaries := make(map[string]*types.WorkflowVCStatusSummary, len(workflowIDs))
75+
uniqueIDs := make([]string, 0, len(workflowIDs))
76+
seen := make(map[string]struct{}, len(workflowIDs))
77+
78+
for _, id := range workflowIDs {
79+
if id == "" {
80+
continue
81+
}
82+
if _, exists := summaries[id]; !exists {
83+
summaries[id] = types.DefaultWorkflowVCStatusSummary(id)
84+
}
85+
if _, exists := seen[id]; !exists {
86+
seen[id] = struct{}{}
87+
uniqueIDs = append(uniqueIDs, id)
88+
}
89+
}
90+
91+
if len(uniqueIDs) == 0 {
92+
return summaries, nil
93+
}
94+
95+
if s == nil || s.config == nil || !s.config.Enabled || s.vcStorage == nil {
96+
return summaries, nil
97+
}
98+
99+
ctx := context.Background()
100+
aggregations, err := s.vcStorage.ListWorkflowVCStatusSummaries(ctx, uniqueIDs)
101+
if err != nil {
102+
return nil, err
103+
}
104+
105+
for _, agg := range aggregations {
106+
if agg == nil {
107+
continue
108+
}
109+
110+
summary := types.DefaultWorkflowVCStatusSummary(agg.WorkflowID)
111+
summary.VCCount = agg.VCCount
112+
summary.VerifiedCount = agg.VerifiedCount
113+
summary.FailedCount = agg.FailedCount
114+
summary.HasVCs = agg.VCCount > 0
115+
116+
if agg.LastCreatedAt != nil {
117+
summary.LastVCCreated = agg.LastCreatedAt.UTC().Format(time.RFC3339)
118+
}
119+
120+
switch {
121+
case agg.VCCount == 0:
122+
summary.VerificationStatus = "none"
123+
case agg.FailedCount > 0:
124+
summary.VerificationStatus = "failed"
125+
case agg.VerifiedCount == agg.VCCount:
126+
summary.VerificationStatus = "verified"
127+
default:
128+
summary.VerificationStatus = "pending"
129+
}
130+
131+
summaries[agg.WorkflowID] = summary
132+
}
133+
134+
return summaries, nil
135+
}
136+
72137
// GenerateExecutionVC generates a verifiable credential for an execution.
73138
func (s *VCService) GenerateExecutionVC(ctx *types.ExecutionContext, inputData, outputData []byte, status string, errorMessage *string, durationMS int) (*types.ExecutionVC, error) {
74139

control-plane/internal/services/vc_storage.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@ func (s *VCStorage) QueryExecutionVCs(filters *types.VCFilters) ([]types.Executi
107107
return s.loadExecutionVCsFromDatabaseWithFilters(applied)
108108
}
109109

110+
// ListWorkflowVCStatusSummaries returns aggregated VC statistics for the provided workflow IDs.
111+
func (s *VCStorage) ListWorkflowVCStatusSummaries(ctx context.Context, workflowIDs []string) ([]*types.WorkflowVCStatusAggregation, error) {
112+
if s.storageProvider == nil {
113+
return nil, fmt.Errorf("no storage provider configured for VC storage")
114+
}
115+
if len(workflowIDs) == 0 {
116+
return []*types.WorkflowVCStatusAggregation{}, nil
117+
}
118+
return s.storageProvider.ListWorkflowVCStatusSummaries(ctx, workflowIDs)
119+
}
120+
110121
// StoreWorkflowVC persists workflow-level VC metadata.
111122
func (s *VCStorage) StoreWorkflowVC(ctx context.Context, vc *types.WorkflowVC) error {
112123
if s.storageProvider == nil {

control-plane/internal/storage/local.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6510,6 +6510,78 @@ func (ls *LocalStorage) ListExecutionVCs(ctx context.Context, filters types.VCFi
65106510
return infos, nil
65116511
}
65126512

6513+
func (ls *LocalStorage) ListWorkflowVCStatusSummaries(ctx context.Context, workflowIDs []string) ([]*types.WorkflowVCStatusAggregation, error) {
6514+
if err := ctx.Err(); err != nil {
6515+
return nil, fmt.Errorf("context cancelled during workflow VC status summary query: %w", err)
6516+
}
6517+
6518+
if len(workflowIDs) == 0 {
6519+
return []*types.WorkflowVCStatusAggregation{}, nil
6520+
}
6521+
6522+
placeholders := make([]string, len(workflowIDs))
6523+
for i := range workflowIDs {
6524+
placeholders[i] = "?"
6525+
}
6526+
6527+
query := fmt.Sprintf(`
6528+
SELECT workflow_id,
6529+
COUNT(*) AS vc_count,
6530+
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS verified_count,
6531+
SUM(CASE WHEN status = ? OR status = ? THEN 1 ELSE 0 END) AS failed_count,
6532+
MAX(created_at) AS last_created_at
6533+
FROM execution_vcs
6534+
WHERE workflow_id IN (%s)
6535+
GROUP BY workflow_id
6536+
`, strings.Join(placeholders, ","))
6537+
6538+
args := []interface{}{
6539+
string(types.ExecutionStatusSucceeded),
6540+
string(types.ExecutionStatusFailed),
6541+
string(types.ExecutionStatusTimeout),
6542+
}
6543+
for _, id := range workflowIDs {
6544+
args = append(args, id)
6545+
}
6546+
6547+
rows, err := ls.db.QueryContext(ctx, query, args...)
6548+
if err != nil {
6549+
return nil, fmt.Errorf("failed to query workflow VC status summaries: %w", err)
6550+
}
6551+
defer rows.Close()
6552+
6553+
var summaries []*types.WorkflowVCStatusAggregation
6554+
for rows.Next() {
6555+
if err := ctx.Err(); err != nil {
6556+
return nil, fmt.Errorf("context cancelled during workflow VC status iteration: %w", err)
6557+
}
6558+
6559+
var lastCreated sql.NullTime
6560+
summary := &types.WorkflowVCStatusAggregation{}
6561+
if err := rows.Scan(
6562+
&summary.WorkflowID,
6563+
&summary.VCCount,
6564+
&summary.VerifiedCount,
6565+
&summary.FailedCount,
6566+
&lastCreated,
6567+
); err != nil {
6568+
return nil, fmt.Errorf("failed to scan workflow VC status summary: %w", err)
6569+
}
6570+
6571+
if lastCreated.Valid {
6572+
summary.LastCreatedAt = &lastCreated.Time
6573+
}
6574+
6575+
summaries = append(summaries, summary)
6576+
}
6577+
6578+
if err := rows.Err(); err != nil {
6579+
return nil, fmt.Errorf("workflow VC status summary rows error: %w", err)
6580+
}
6581+
6582+
return summaries, nil
6583+
}
6584+
65136585
func (ls *LocalStorage) CountExecutionVCs(ctx context.Context, filters types.VCFilters) (int, error) {
65146586
if err := ctx.Err(); err != nil {
65156587
return 0, fmt.Errorf("context cancelled during count execution VCs: %w", err)

control-plane/internal/storage/storage.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ type StorageProvider interface {
167167
StoreExecutionVC(ctx context.Context, vcID, executionID, workflowID, sessionID, issuerDID, targetDID, callerDID, inputHash, outputHash, status string, vcDocument []byte, signature string, storageURI string, documentSizeBytes int64) error
168168
GetExecutionVC(ctx context.Context, vcID string) (*types.ExecutionVCInfo, error)
169169
ListExecutionVCs(ctx context.Context, filters types.VCFilters) ([]*types.ExecutionVCInfo, error)
170+
ListWorkflowVCStatusSummaries(ctx context.Context, workflowIDs []string) ([]*types.WorkflowVCStatusAggregation, error)
170171
CountExecutionVCs(ctx context.Context, filters types.VCFilters) (int, error)
171172

172173
// Workflow VC operations

control-plane/pkg/types/did_types.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,39 @@ type WorkflowVCChainResponse struct {
294294
DIDResolutionBundle map[string]DIDResolutionEntry `json:"did_resolution_bundle,omitempty"`
295295
}
296296

297+
// WorkflowVCStatusAggregation represents aggregated VC stats per workflow directly from storage.
298+
type WorkflowVCStatusAggregation struct {
299+
WorkflowID string `json:"workflow_id"`
300+
VCCount int `json:"vc_count"`
301+
VerifiedCount int `json:"verified_count"`
302+
FailedCount int `json:"failed_count"`
303+
LastCreatedAt *time.Time `json:"last_created_at,omitempty"`
304+
}
305+
306+
// WorkflowVCStatusSummary is the UI-facing summary for workflow VC status indicators.
307+
type WorkflowVCStatusSummary struct {
308+
WorkflowID string `json:"workflow_id"`
309+
HasVCs bool `json:"has_vcs"`
310+
VCCount int `json:"vc_count"`
311+
VerifiedCount int `json:"verified_count"`
312+
FailedCount int `json:"failed_count"`
313+
LastVCCreated string `json:"last_vc_created"`
314+
VerificationStatus string `json:"verification_status"`
315+
}
316+
317+
// DefaultWorkflowVCStatusSummary creates an empty summary for workflows with no VC data.
318+
func DefaultWorkflowVCStatusSummary(workflowID string) *WorkflowVCStatusSummary {
319+
return &WorkflowVCStatusSummary{
320+
WorkflowID: workflowID,
321+
HasVCs: false,
322+
VCCount: 0,
323+
VerifiedCount: 0,
324+
FailedCount: 0,
325+
LastVCCreated: "",
326+
VerificationStatus: "none",
327+
}
328+
}
329+
297330
// DIDResolutionEntry represents a resolved DID with its public key for offline verification.
298331
type DIDResolutionEntry struct {
299332
Method string `json:"method"` // "key", "web", etc.

control-plane/web/client/src/components/CompactWorkflowsTable.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import { useMemo, useState, useEffect } from "react";
44
import type { ReactNode } from "react";
55
import { Renew, Security, TrashCan } from "@/components/ui/icon-bridge";
6-
import { useVCStatus } from "../hooks/useVCVerification";
6+
import { useWorkflowVCStatuses } from "../hooks/useVCVerification";
77
import type { WorkflowSummary } from "../types/workflows";
8+
import type { VCStatusSummary } from "../types/did";
89
import { Button } from "./ui/button";
910
import { Card, CardContent } from "./ui/card";
1011
import { Checkbox } from "./ui/checkbox";
12+
import { Skeleton } from "./ui/skeleton";
1113
import {
1214
HoverCard,
1315
HoverCardContent,
@@ -165,17 +167,34 @@ function WorkflowHoverCard({
165167
);
166168
}
167169

168-
function WorkflowVCStatusCell({ workflowId }: { workflowId: string }) {
169-
const { status: vcStatus } = useVCStatus(workflowId);
170+
function WorkflowVCStatusCell({
171+
workflowId,
172+
summary,
173+
loading,
174+
}: {
175+
workflowId: string;
176+
summary?: VCStatusSummary | null;
177+
loading?: boolean;
178+
}) {
179+
if (loading && !summary) {
180+
return <Skeleton className="h-3 w-12 rounded-full bg-muted/20" />;
181+
}
170182

171-
if (!vcStatus) {
172-
return <VerifiableCredentialBadge hasVC={false} status="none" />;
183+
if (!summary) {
184+
return (
185+
<VerifiableCredentialBadge
186+
hasVC={false}
187+
status="none"
188+
workflowId={workflowId}
189+
variant="table"
190+
/>
191+
);
173192
}
174193

175194
return (
176195
<VerifiableCredentialBadge
177-
hasVC={vcStatus.has_vcs}
178-
status={vcStatus.verification_status}
196+
hasVC={summary.has_vcs}
197+
status={summary.verification_status}
179198
workflowId={workflowId}
180199
variant="table"
181200
/>
@@ -198,6 +217,12 @@ export function CompactWorkflowsTable({
198217
const [selectedWorkflows, setSelectedWorkflows] = useState<Set<string>>(new Set());
199218
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
200219
const [searchQuery, setSearchQuery] = useState("");
220+
const workflowIds = useMemo(
221+
() => workflows.map((workflow) => workflow.workflow_id).filter(Boolean),
222+
[workflows]
223+
);
224+
const { statuses: workflowVCStatuses, loading: workflowVCStatusesLoading } =
225+
useWorkflowVCStatuses(workflowIds);
201226

202227
// Define search fields for workflows
203228
const searchFields = [
@@ -497,7 +522,11 @@ export function CompactWorkflowsTable({
497522
sortable: false,
498523
align: "center" as const,
499524
render: (workflow: WorkflowSummary) => (
500-
<WorkflowVCStatusCell workflowId={workflow.workflow_id} />
525+
<WorkflowVCStatusCell
526+
workflowId={workflow.workflow_id}
527+
summary={workflowVCStatuses[workflow.workflow_id]}
528+
loading={workflowVCStatusesLoading}
529+
/>
501530
),
502531
},
503532
{

0 commit comments

Comments
 (0)