From 35cc59ea706cf7d92e9c7fe2d0e738c249e1a6a1 Mon Sep 17 00:00:00 2001 From: Paulo Cesar <461084+pocesar@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:31:45 -0300 Subject: [PATCH 1/2] chore: remove .DS_Store files and gitignore them --- .gitignore | 1 + control-plane/.DS_Store | Bin 8196 -> 0 bytes control-plane/internal/.DS_Store | Bin 10244 -> 0 bytes control-plane/internal/storage/.DS_Store | Bin 6148 -> 0 bytes control-plane/web/.DS_Store | Bin 6148 -> 0 bytes sdk/typescript/package-lock.json | 4 ++-- 6 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 control-plane/.DS_Store delete mode 100644 control-plane/internal/.DS_Store delete mode 100644 control-plane/internal/storage/.DS_Store delete mode 100644 control-plane/web/.DS_Store diff --git a/.gitignore b/.gitignore index ea605e80e..e1fe64235 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # General .DS_Store +**/.DS_Store *.log *.tmp *.swp diff --git a/control-plane/.DS_Store b/control-plane/.DS_Store deleted file mode 100644 index ea68bea5dab425f018f7499f075cf42bfd16f990..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMF>ljA6n=-(5`id{81O)m5i=6~2W}N4Bp5(tV@Z=3$0EiN4Py1cPzDB8_z#Ro z6vV&)3lkCp0}^YQ_yG)j_wLj@$9545qT-!&_c{08yZ8CK(_JnhBD48mv`!QfQ5V(X z>Pa*W#oBGHm1Djq0vg2A`qr?>r!_zAPz6*0RX`O`1yq55M**DKwsc$0eP`8H6;K8K zO9kxy5TRO(J(do6>p;U-0AL5*T=2YVAApg^*kkDs78K(`fiBeeD~565=#RW#?6Gv{ z!pZo{hw+(>zo8h;j`1UQClfo=RuxbM>Iy{Y3TQ|EohZ=1z9MUV0UB?#dW>F=x=`Q{}(t^q*X(z`6`pSM4`@nv7gx@g*I?!?lFPdw%nS1CmE)vBb!#Ob{lK7f9BX6e>o^X9UBf2(@zv2@52frd|Epq~HGMT;tMloSXY+0fqqueAUEf0SNHxvB!Hz#l4L zI=!vlCW81>ZwR)he1zIZwZ;0C4mku(<%Epm6n@@K94~=3NP(tsam9hEio~{Q`4LoFLO|R=EKm@XWH-A_ta$C!?j{k6 zBKd+)IaG)X?E$GLBm}4u9D?A&2{>|r6G)XfaPNUrCHlRw*ZaoyZsAZ=q#4Weyq@pP z`~1z1J+nk)EpN9@61ha=;AA^@FS3Hf<$UH+DY^3ktOb7}kJhO{1K2Q!HaCm{MggOM zQNSo*6u27{z&o3hGaFlz8wHF4MuAiTo*!JCY-@>)VymVOWNHZjJB8aS;Tr1z<>Mx{ zme?q^$`y6Ss|PD_wUQ-<6}zL{WjJgtu~BSecd}x4vXaPFvO=*`bojXnPF5|pCN~Ng z1@a2;+I<#l_AU-zTfYbX#>x76*y+Yj_g@>2?1U>TO+Rb`;XE|o`r**RkIzm*xBdmX zyD3??sFupuWdK)=U#WC+R3iDDdpYHk_;tcj{Cuevuk)CP{ z``fP;zW(Ew(n704G8l)^cu>SVrWB7JwKWdblb^{l8UM!R%WG$C6+iWrv5U&JxYmap z*P%9bsENFV1GyHToEM8QlE?3#q-~I^OY)h7(|E-%iF?2$fF?b97ri&$b;|VG!w-X7 zAx`r1zDso)gB|sl+hGfM+TgjPxDKeVYqz=sepia4Hw(bqu4E#&;{nLhs0 z7eyG!Vd4Gs`EzwiHj{7~FS^dWZ1g7X=REc@?Q!YRY`Nq!{mjwPye;aGT*hH^9#@%% zlk(vG@vJ`Oxq1;s^7-?o&}YaesY`O2gwuGH)k}RwjkZvYJG5JKx8F_P;s?#H@AU`O z>aSL*JT-04I5W@^`}-B= zfkz&D`jyo!zY~NPSxtP~Cu;z@FaDk8AK)tUP}(4{w?57zq|k4Enl^l<9x3y9lqRyR zJ{~gl?9_XK9|jxX!B0`aG{-Mj{`lfQPO_bYemho-lB|y$Jb3N6rKY)p`XuwoNR7P} zAuvs266P1HpB>ak-_uJapEGZG!|6=;OCH4tEk0E_zDY&_qkvJsC~yZ9D93fWy#N3F z`2YX!Kx9*;QNSp0wQ`_;dYC9f3csu?$ tPR6ERa=5M~_oCS14$^=AXFz6;L4KN=^}kfZtpC%sJN6KyGXMAW|1B18BB=lX diff --git a/control-plane/internal/storage/.DS_Store b/control-plane/internal/storage/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0dF8WK^3BFfU}FmZPdop^8`%DToAJvR@d>7re4Iu`ni zE_wG;T2MoebWeNlKe)Pk-ydJMbvX8CK(X7$7Ad@KCJfQ{6gH=@kGf$7nUQ^(+fb;(%t?VhRt8 zZ7S5JvR^USro*2+F1r>}n@+4x#ybAwugeSjNt%;}6KB(?gFq12CD60(Q1br-zf$KV ze|Jjsfi7V28p{=PHs;L+q*+(vQ(8iOrF`iwE UsjMP?oeqtIfC7ms2poaHCzAXuWB>pF diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 2b0d2c346..0cbf58c4c 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentfield/sdk", - "version": "0.1.87", + "version": "0.1.89", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentfield/sdk", - "version": "0.1.87", + "version": "0.1.89", "license": "Apache-2.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.81", From 239e96c224e4e9a19cd9a12389b5a99bcf6f15c9 Mon Sep 17 00:00:00 2001 From: Paulo Cesar <461084+pocesar@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:16:17 -0300 Subject: [PATCH 2/2] fix: tech debt --- compose.yaml | 78 ++ control-plane/.env.example | 13 +- control-plane/config/agentfield.yaml | 14 +- control-plane/config/docker-perf.yaml | 108 --- control-plane/internal/core/domain/models.go | 15 +- .../internal/core/services/agent_service.go | 73 +- .../core/services/agent_service_test.go | 26 +- .../core/services/coverage_additional_test.go | 76 +- .../core/services/coverage_gap_test.go | 80 +- .../core/services/coverage_targeted_test.go | 191 +++-- .../internal/core/services/package_service.go | 86 +-- .../core/services/package_service_test.go | 113 +-- .../internal/core/services/registry_bridge.go | 129 ++++ .../internal/handlers/agentic/status_test.go | 4 +- .../internal/handlers/config_storage_test.go | 4 +- .../handlers/connector/handlers_test.go | 4 +- .../handlers/coverage_additional_test.go | 52 +- .../handlers/coverage_handlers_90_test.go | 49 +- .../coverage_handlers_92_target_test.go | 36 +- .../handlers/coverage_handlers_gap_test.go | 307 -------- .../handlers/coverage_low_branches_test.go | 62 +- .../handlers/coverage_raise_88_test.go | 186 ----- .../internal/handlers/empty_input_test.go | 292 ------- .../internal/handlers/execute_test.go | 12 +- .../internal/handlers/execution_cleanup.go | 44 +- .../handlers/execution_cleanup_test.go | 36 +- control-plane/internal/handlers/nodes_rest.go | 58 -- .../internal/handlers/nodes_rest_test.go | 31 +- .../handlers/nodes_split_coverage_test.go | 65 -- .../internal/handlers/nodes_status.go | 202 ----- control-plane/internal/handlers/reasoners.go | 726 ------------------ .../internal/handlers/reasoners_test.go | 378 --------- ...rage_identity_dashboard_additional_test.go | 203 ----- .../internal/handlers/ui/identity.go | 576 -------------- .../handlers/ui/identity_handlers_test.go | 152 ---- .../internal/handlers/ui/reasoners.go | 22 +- .../server/apicatalog/catalog_entries.go | 40 +- control-plane/internal/server/routes_core.go | 32 - control-plane/internal/server/routes_did.go | 3 - control-plane/internal/server/routes_ui.go | 4 - control-plane/internal/server/server.go | 74 +- .../internal/server/server_additional_test.go | 41 - .../server/server_coverage_additional_test.go | 2 +- .../internal/server/server_grpc_test.go | 61 -- .../internal/server/server_routes_test.go | 4 +- .../services/executions_ui_service.go | 6 - .../executions_ui_service_additional_test.go | 2 - ...cutions_ui_service_sort_additional_test.go | 13 - .../coverage_sqlite_vector_locks_test.go | 21 +- .../storage/coverage_storage_clinch_test.go | 6 +- .../internal/storage/helpers_test.go | 16 + control-plane/internal/storage/local.go | 57 +- .../internal/storage/local_cleanup_test.go | 2 +- control-plane/internal/storage/locks.go | 80 +- control-plane/internal/storage/payload_uri.go | 31 + .../internal/storage/payload_uri_test.go | 20 + control-plane/internal/storage/sql_helpers.go | 4 + .../storage/stale_retry_postgres_test.go | 83 ++ control-plane/internal/storage/storage.go | 4 +- .../034_workflow_execution_retry_count.sql | 9 + .../pkg/adminpb/reasoner_admin.pb.go | 267 ------- .../pkg/adminpb/reasoner_admin_grpc.pb.go | 127 --- control-plane/pkg/types/execution.go | 55 -- .../proto/admin/reasoner_admin.proto | 27 - .../web/client/src/components/StepDetail.tsx | 11 +- .../WorkflowDAG/NodeDetailSidebar.tsx | 13 +- .../execution/ExecutionRetryPanel.tsx | 60 +- .../components/triggers/EventDetailPanel.tsx | 13 +- .../components/triggers/NewTriggerDialog.tsx | 44 +- .../src/components/triggers/TriggerSheet.tsx | 23 +- control-plane/web/client/src/hooks/useSSE.ts | 13 - control-plane/web/client/src/lib/cpClient.ts | 183 +++++ .../web/client/src/lib/governanceProbe.ts | 17 +- .../web/client/src/pages/IntegrationsPage.tsx | 48 +- .../src/pages/NewDashboardPage.test.tsx | 3 +- .../web/client/src/pages/NewDashboardPage.tsx | 3 +- .../web/client/src/pages/NewSettingsPage.tsx | 65 +- .../web/client/src/pages/TriggersPage.tsx | 85 +- .../client/src/services/accessPoliciesApi.ts | 21 +- control-plane/web/client/src/services/api.ts | 132 +--- .../client/src/services/configurationApi.ts | 18 +- .../client/src/services/dashboardService.ts | 76 +- .../web/client/src/services/didApi.ts | 18 +- .../src/services/executionTimelineService.ts | 52 +- .../web/client/src/services/executionsApi.ts | 22 +- .../web/client/src/services/identityApi.ts | 253 ------ .../src/services/observabilityWebhookApi.ts | 18 +- .../web/client/src/services/reasonersApi.ts | 13 +- .../src/services/recentActivityService.ts | 52 +- .../web/client/src/services/tagApprovalApi.ts | 21 +- .../web/client/src/services/triggersApi.ts | 149 ++++ .../web/client/src/services/vcApi.ts | 38 +- .../web/client/src/services/workflowsApi.ts | 25 +- .../web/client/src/test/lib/libUtils.test.ts | 19 +- .../src/test/pages/NewDashboardPage.test.tsx | 3 + .../pages/NewSettingsPage.restored.test.tsx | 2 - .../src/test/services/identityApi.test.ts | 209 ----- docs/ENVIRONMENT_VARIABLES.md | 138 ++-- 98 files changed, 1370 insertions(+), 6084 deletions(-) create mode 100644 compose.yaml delete mode 100644 control-plane/config/docker-perf.yaml create mode 100644 control-plane/internal/core/services/registry_bridge.go delete mode 100644 control-plane/internal/handlers/empty_input_test.go delete mode 100644 control-plane/internal/handlers/reasoners.go delete mode 100644 control-plane/internal/handlers/reasoners_test.go delete mode 100644 control-plane/internal/handlers/ui/coverage_identity_dashboard_additional_test.go delete mode 100644 control-plane/internal/handlers/ui/identity.go delete mode 100644 control-plane/internal/handlers/ui/identity_handlers_test.go delete mode 100644 control-plane/internal/server/server_grpc_test.go delete mode 100644 control-plane/internal/services/executions_ui_service_sort_additional_test.go create mode 100644 control-plane/internal/storage/payload_uri.go create mode 100644 control-plane/internal/storage/payload_uri_test.go create mode 100644 control-plane/internal/storage/stale_retry_postgres_test.go create mode 100644 control-plane/migrations/034_workflow_execution_retry_count.sql delete mode 100644 control-plane/pkg/adminpb/reasoner_admin.pb.go delete mode 100644 control-plane/pkg/adminpb/reasoner_admin_grpc.pb.go delete mode 100644 control-plane/proto/admin/reasoner_admin.proto create mode 100644 control-plane/web/client/src/lib/cpClient.ts delete mode 100644 control-plane/web/client/src/services/identityApi.ts create mode 100644 control-plane/web/client/src/services/triggersApi.ts delete mode 100644 control-plane/web/client/src/test/services/identityApi.test.ts diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..890d24b68 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,78 @@ +services: + db: + image: bitnami/postgresql:latest + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 10 + ports: + - 5432:5432 + volumes: + - db_data:/var/lib/postgresql/data + + control-plane: + build: + context: . + dockerfile: deployments/docker/Dockerfile.control-plane + command: + - server + - --config=/etc/agentfield/config/agentfield.yaml + - --verbose=true + - --storage-mode=postgres + - --open=false + environment: + AGENTFIELD_STORAGE_MODE: postgres + AGENTFIELD_HOME: /data + AGENTFIELD_POSTGRES_URL: postgres://postgres:postgres@db:5432/postgres?default_query_exec_mode=cache_describe + AGENTFIELD_CONFIG_FILE: /etc/agentfield/config/agentfield.yaml + AGENTFIELD_API_AUTH_API_KEY: abc123 + AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN: admin123 + GIN_MODE: release + NODE_ENV: production + depends_on: + db: + condition: service_healthy + ports: + - 8888:8080 + volumes: + - control-plane-data:/data + - ./control-plane/config/agentfield.yaml:/etc/agentfield/config/agentfield.yaml + + agent: &agent-base + build: + context: . + dockerfile: deployments/docker/Dockerfile.python-agent + command: + - python + - -c + - from agent import agent; + agent.run() + volumes: + - ./agent.py:/app/agent.py + depends_on: + control-plane: + condition: service_started + environment: &agent-environment + AGENTFIELD_SERVER: http://control-plane:8080 + PORT: 8001 + AGENT_CALLBACK_URL: http://agent:8001 + + team: + <<: *agent-base + environment: + <<: *agent-environment + AGENT_CALLBACK_URL: http://team:8001 + command: + - python + - -c + - from agent import team; + team.run() + +volumes: + db_data: + control-plane-data: diff --git a/control-plane/.env.example b/control-plane/.env.example index 58f6a1273..c699c9ce0 100644 --- a/control-plane/.env.example +++ b/control-plane/.env.example @@ -28,26 +28,17 @@ AGENTFIELD_API_CORS_ALLOW_CREDENTIALS=true # AGENTFIELD_CLOUD_ENABLED=false # AGENTFIELD_CLOUD_API_KEY=your-api-key-here -# Storage Configuration +# Storage Configuration (supported modes: local, postgres) AGENTFIELD_STORAGE_MODE=local -# PostgreSQL Storage Configuration (when AGENTFIELD_STORAGE_MODE=postgresql) +# PostgreSQL Storage Configuration (when AGENTFIELD_STORAGE_MODE=postgres) # AGENTFIELD_STORAGE_POSTGRES_URL=postgresql://user:password@localhost:5432/agentfield?sslmode=disable # AGENTFIELD_STORAGE_POSTGRES_MAX_CONNECTIONS=25 # AGENTFIELD_STORAGE_POSTGRES_MAX_IDLE_CONNECTIONS=5 # AGENTFIELD_STORAGE_POSTGRES_CONNECTION_TIMEOUT=30s # AGENTFIELD_STORAGE_POSTGRES_QUERY_TIMEOUT=30s -# AGENTFIELD_STORAGE_POSTGRES_ENABLE_MEMORY_FALLBACK=true -# AGENTFIELD_STORAGE_POSTGRES_ENABLE_DID_FALLBACK=true -# AGENTFIELD_STORAGE_POSTGRES_ENABLE_VC_FALLBACK=true # AGENTFIELD_STORAGE_POSTGRES_ENABLE_AUTO_MIGRATION=true -# Cloud Storage Configuration (when AGENTFIELD_STORAGE_MODE=cloud) -# AGENTFIELD_STORAGE_CLOUD_POSTGRES_URL=postgresql://user:password@localhost:5432/agentfield -# AGENTFIELD_STORAGE_CLOUD_MAX_CONNECTIONS=50 -# AGENTFIELD_STORAGE_CLOUD_CONNECTION_POOL=true -# AGENTFIELD_STORAGE_CLOUD_REPLICATION_MODE=async - # VC Authorization # Admin token for admin API endpoints (tag approval, policy management) # AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN=admin-secret diff --git a/control-plane/config/agentfield.yaml b/control-plane/config/agentfield.yaml index f05d9a29e..27e1faf8c 100644 --- a/control-plane/config/agentfield.yaml +++ b/control-plane/config/agentfield.yaml @@ -22,7 +22,6 @@ agentfield: ui: enabled: true mode: "embedded" - dev_port: 5173 api: cors: @@ -31,6 +30,7 @@ api: - "http://localhost:5173" - "http://localhost:8001" - "http://localhost:8080" + - "http://localhost:8888" allowed_methods: - "GET" - "POST" @@ -54,23 +54,15 @@ api: allow_credentials: true telemetry: - enabled: true + enabled: false mode: "anonymous" endpoint: "https://agentfield.ai/api/oss/telemetry" install_id_path: "/data/telemetry/install_id" timeout: 800ms storage: - mode: "local" - local: - database_path: "" - kv_store_path: "" + mode: "postgres" postgres: - host: "localhost" - port: 5433 - database: "agentfield_dev" - user: "agentfield" - password: "agentfield" sslmode: "disable" vector: enabled: true diff --git a/control-plane/config/docker-perf.yaml b/control-plane/config/docker-perf.yaml deleted file mode 100644 index bd739649c..000000000 --- a/control-plane/config/docker-perf.yaml +++ /dev/null @@ -1,108 +0,0 @@ -agentfield: - port: 8080 - mode: "local" - max_concurrent_requests: 1024 - request_timeout: 180s - circuit_breaker_threshold: 20 - execution_cleanup: - enabled: true - retention_period: 72h - cleanup_interval: 1h - batch_size: 200 - preserve_recent_duration: 1h - execution_queue: - worker_count: 32 - request_timeout: 180s - agent_call_timeout: 90s - lease_duration: 180s - max_attempts: 4 - failure_backoff: 500ms - max_failure_backoff: 30s - poll_interval: 75ms - result_preview_bytes: 4096 - queue_soft_limit: 20000 - waiter_map_limit: 4000 - webhook_timeout: 10s - webhook_max_attempts: 3 - webhook_retry_backoff: 1s - webhook_max_retry_backoff: 5s - -ui: - enabled: true - mode: "embedded" - dev_port: 5173 - backend_url: "" - -api: - cors: - allowed_origins: - - "*" - allowed_methods: - - "GET" - - "POST" - - "PUT" - - "DELETE" - - "OPTIONS" - allowed_headers: - - "Origin" - - "Content-Type" - - "Accept" - - "Authorization" - - "X-Requested-With" - exposed_headers: - - "Content-Length" - - "X-Total-Count" - allow_credentials: false - -storage: - mode: "local" - local: - database_path: "" - kv_store_path: "" - cache_size: 1000 - retention_days: 30 - auto_vacuum: true - vector: - enabled: true - distance: "cosine" - -features: - did: - enabled: true - method: "did:key" - key_algorithm: "Ed25519" - derivation_method: "BIP32" - key_rotation_days: 90 - vc_requirements: - require_vc_registration: true - require_vc_execution: true - require_vc_cross_agent: true - store_input_output: false - hash_sensitive_data: true - keystore: - type: "local" - path: "/var/agentfield/data/keys" - encryption: "AES-256-GCM" - backup_enabled: true - backup_interval: "24h" - -data_directories: - agentfield_home: "/var/agentfield" - database_dir: "data" - keys_dir: "data/keys" - did_registries_dir: "data/did_registries" - vcs_dir: "data/vcs" - agents_dir: "agents" - logs_dir: "logs" - config_dir: "config" - temp_dir: "temp" - payloads_dir: "data/payloads" - -agents: - discovery: - scan_interval: "30s" - health_check_interval: "15s" - scaling: - auto_scale: false - min_replicas: 1 - max_replicas: 16 diff --git a/control-plane/internal/core/domain/models.go b/control-plane/internal/core/domain/models.go index efd9992b8..53cf5cc2e 100644 --- a/control-plane/internal/core/domain/models.go +++ b/control-plane/internal/core/domain/models.go @@ -47,13 +47,26 @@ type InstallationRegistry struct { Installed map[string]InstalledPackage `json:"installed"` } +// PackageRuntime tracks live process metadata for an installed package. +type PackageRuntime struct { + Port *int `json:"port,omitempty"` + PID *int `json:"pid,omitempty"` + StartedAt *string `json:"started_at,omitempty"` + LogFile string `json:"log_file,omitempty"` +} + // InstalledPackage represents an installed package type InstalledPackage struct { Name string `json:"name"` Version string `json:"version"` Path string `json:"path"` - Environment map[string]string `json:"environment"` + Description string `json:"description,omitempty"` + Source string `json:"source,omitempty"` + SourcePath string `json:"source_path,omitempty"` + Status string `json:"status,omitempty"` + Environment map[string]string `json:"environment,omitempty"` InstalledAt time.Time `json:"installed_at"` + Runtime PackageRuntime `json:"runtime,omitempty"` } // AgentFieldConfig represents the AgentField configuration diff --git a/control-plane/internal/core/services/agent_service.go b/control-plane/internal/core/services/agent_service.go index 6757b7b6a..76b68c695 100644 --- a/control-plane/internal/core/services/agent_service.go +++ b/control-plane/internal/core/services/agent_service.go @@ -4,7 +4,6 @@ package services import ( "context" "encoding/json" - "errors" "fmt" "net/http" "os" @@ -18,7 +17,6 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/core/domain" "github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces" "github.com/Agent-Field/agentfield/control-plane/internal/packages" - "gopkg.in/yaml.v3" ) // DefaultAgentService implements the AgentService interface @@ -52,7 +50,7 @@ func (as *DefaultAgentService) RunAgent(name string, options domain.RunOptions) fmt.Printf("πŸš€ Launching agent node: %s\n", name) // 1. Check if agent node is installed - registry, err := as.loadRegistryDirect() + registry, err := loadPackagesRegistry(as.registryStorage) if err != nil { return nil, fmt.Errorf("failed to load registry: %w", err) } @@ -71,7 +69,7 @@ func (as *DefaultAgentService) RunAgent(name string, options domain.RunOptions) if wasReconciled { // Save reconciled state registry.Installed[name] = agentNode - if err := as.saveRegistryDirect(registry); err != nil { + if err := savePackagesRegistry(as.registryStorage, registry); err != nil { fmt.Printf("Warning: failed to save reconciled registry state: %v\n", err) } } @@ -138,7 +136,7 @@ func (as *DefaultAgentService) RunAgent(name string, options domain.RunOptions) // StopAgent stops a running agent with robust error handling func (as *DefaultAgentService) StopAgent(name string) error { // Load registry to get agent info - registry, err := as.loadRegistryDirect() + registry, err := loadPackagesRegistry(as.registryStorage) if err != nil { return fmt.Errorf("failed to load registry: %w", err) } @@ -157,7 +155,7 @@ func (as *DefaultAgentService) StopAgent(name string) error { if wasReconciled { // Save reconciled state registry.Installed[name] = pkg - if err := as.saveRegistryDirect(registry); err != nil { + if err := savePackagesRegistry(as.registryStorage, registry); err != nil { fmt.Printf("Warning: failed to save reconciled registry state: %v\n", err) } } @@ -220,7 +218,7 @@ func (as *DefaultAgentService) StopAgent(name string) error { pkg.Runtime.Port = nil pkg.Runtime.StartedAt = nil registry.Installed[name] = pkg - if err := as.saveRegistryDirect(registry); err != nil { + if err := savePackagesRegistry(as.registryStorage, registry); err != nil { return fmt.Errorf("failed to update registry: %w", err) } return nil @@ -261,7 +259,7 @@ func (as *DefaultAgentService) StopAgent(name string) error { registry.Installed[name] = pkg // Save registry - if err := as.saveRegistryDirect(registry); err != nil { + if err := savePackagesRegistry(as.registryStorage, registry); err != nil { return fmt.Errorf("failed to update registry: %w", err) } @@ -270,7 +268,7 @@ func (as *DefaultAgentService) StopAgent(name string) error { // GetAgentStatus returns the status of a specific agent with process reconciliation func (as *DefaultAgentService) GetAgentStatus(name string) (*domain.AgentStatus, error) { - registry, err := as.loadRegistryDirect() + registry, err := loadPackagesRegistry(as.registryStorage) if err != nil { return nil, fmt.Errorf("failed to load registry: %w", err) } @@ -289,7 +287,7 @@ func (as *DefaultAgentService) GetAgentStatus(name string) (*domain.AgentStatus, if reconciled { // Save updated registry if reconciliation occurred registry.Installed[name] = pkg - if err := as.saveRegistryDirect(registry); err != nil { + if err := savePackagesRegistry(as.registryStorage, registry); err != nil { fmt.Printf("Warning: failed to save reconciled registry state: %v\n", err) } } @@ -372,7 +370,7 @@ func (as *DefaultAgentService) reconcileProcessState(pkg *packages.InstalledPack // ListRunningAgents returns a list of all running agents func (as *DefaultAgentService) ListRunningAgents() ([]domain.RunningAgent, error) { - registry, err := as.loadRegistryDirect() + registry, err := loadPackagesRegistry(as.registryStorage) if err != nil { return nil, fmt.Errorf("failed to load registry: %w", err) } @@ -387,37 +385,6 @@ func (as *DefaultAgentService) ListRunningAgents() ([]domain.RunningAgent, error return runningAgents, nil } -// loadRegistryDirect loads the registry using direct file system access -// TODO: Eventually replace with registryStorage interface usage -func (as *DefaultAgentService) loadRegistryDirect() (*packages.InstallationRegistry, error) { - registryPath := filepath.Join(as.agentfieldHome, "installed.yaml") - - registry := &packages.InstallationRegistry{ - Installed: make(map[string]packages.InstalledPackage), - } - - if data, err := os.ReadFile(registryPath); err == nil { - if err := yaml.Unmarshal(data, registry); err != nil { - return nil, fmt.Errorf("failed to parse registry: %w", err) - } - } - - return registry, nil -} - -// saveRegistryDirect saves the registry using direct file system access -// TODO: Eventually replace with registryStorage interface usage -func (as *DefaultAgentService) saveRegistryDirect(registry *packages.InstallationRegistry) error { - registryPath := filepath.Join(as.agentfieldHome, "installed.yaml") - - data, err := yaml.Marshal(registry) - if err != nil { - return fmt.Errorf("failed to marshal registry: %w", err) - } - - return os.WriteFile(registryPath, data, 0644) -} - // convertToRunningAgent converts packages.InstalledPackage to domain.RunningAgent func (as *DefaultAgentService) convertToRunningAgent(pkg packages.InstalledPackage) domain.RunningAgent { agent := domain.RunningAgent{ @@ -547,19 +514,11 @@ func (as *DefaultAgentService) waitForAgentNode(port int, timeout time.Duration) // updateRuntimeInfo updates the registry with runtime information func (as *DefaultAgentService) updateRuntimeInfo(agentNodeName string, port, pid int) error { - registryPath := filepath.Join(as.agentfieldHome, "installed.yaml") - - // Load registry - registry := &packages.InstallationRegistry{} - if data, err := os.ReadFile(registryPath); err == nil { - if err := yaml.Unmarshal(data, registry); err != nil { - return fmt.Errorf("failed to parse registry: %w", err) - } - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("failed to read registry: %w", err) + registry, err := loadPackagesRegistry(as.registryStorage) + if err != nil { + return err } - // Update runtime info if agentNode, exists := registry.Installed[agentNodeName]; exists { startedAt := time.Now().Format(time.RFC3339) agentNode.Status = "running" @@ -569,13 +528,7 @@ func (as *DefaultAgentService) updateRuntimeInfo(agentNodeName string, port, pid registry.Installed[agentNodeName] = agentNode } - // Save registry - data, err := yaml.Marshal(registry) - if err != nil { - return err - } - - return os.WriteFile(registryPath, data, 0644) + return savePackagesRegistry(as.registryStorage, registry) } // displayCapabilities fetches and displays agent node capabilities diff --git a/control-plane/internal/core/services/agent_service_test.go b/control-plane/internal/core/services/agent_service_test.go index d8438e2f3..e5244b213 100644 --- a/control-plane/internal/core/services/agent_service_test.go +++ b/control-plane/internal/core/services/agent_service_test.go @@ -14,7 +14,6 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/packages" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" ) // Mock implementations for testing @@ -189,14 +188,8 @@ func (m *mockAgentClient) GetAgentStatus(ctx context.Context, nodeID string) (*i return nil, errors.New("not implemented") } -// Helper function to create a test registry file -func createTestRegistry(t *testing.T, dir string, registry *packages.InstallationRegistry) string { - registryPath := filepath.Join(dir, "installed.yaml") - data, err := yaml.Marshal(registry) - require.NoError(t, err) - err = os.WriteFile(registryPath, data, 0644) - require.NoError(t, err) - return registryPath +func seedMockRegistry(storage *mockRegistryStorage, registry *packages.InstallationRegistry) { + storage.registry = packagesRegistryToDomain(registry) } func TestNewAgentService(t *testing.T) { @@ -246,11 +239,10 @@ func TestRunAgent_Success(t *testing.T) { }, }, } - createTestRegistry(t, agentfieldHome, registry) - processManager := newMockProcessManager() portManager := newMockPortManager() registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) agentClient := newMockAgentClient() service := NewAgentService( @@ -344,8 +336,6 @@ func TestRunAgent_AlreadyRunning(t *testing.T) { }, }, } - createTestRegistry(t, agentfieldHome, registry) - processManager := newMockProcessManager() // Mock process manager to report process as running processManager.statusFunc = func(pid int) (interfaces.ProcessInfo, error) { @@ -359,6 +349,7 @@ func TestRunAgent_AlreadyRunning(t *testing.T) { portManager := newMockPortManager() registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) agentClient := newMockAgentClient() service := NewAgentService( @@ -423,8 +414,6 @@ func TestStopAgent_Success(t *testing.T) { }, }, } - createTestRegistry(t, agentfieldHome, registry) - processManager := newMockProcessManager() processManager.statusFunc = func(pid int) (interfaces.ProcessInfo, error) { return interfaces.ProcessInfo{ @@ -437,6 +426,7 @@ func TestStopAgent_Success(t *testing.T) { portManager := newMockPortManager() registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) agentClient := newMockAgentClient() // Mock successful HTTP shutdown @@ -515,8 +505,6 @@ func TestGetAgentStatus_Success(t *testing.T) { }, }, } - createTestRegistry(t, agentfieldHome, registry) - processManager := newMockProcessManager() processManager.statusFunc = func(pid int) (interfaces.ProcessInfo, error) { return interfaces.ProcessInfo{ @@ -529,6 +517,7 @@ func TestGetAgentStatus_Success(t *testing.T) { portManager := newMockPortManager() registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) agentClient := newMockAgentClient() service := NewAgentService( @@ -738,11 +727,10 @@ func TestListRunningAgents(t *testing.T) { }, }, } - createTestRegistry(t, agentfieldHome, registry) - processManager := newMockProcessManager() portManager := newMockPortManager() registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) agentClient := newMockAgentClient() service := NewAgentService( diff --git a/control-plane/internal/core/services/coverage_additional_test.go b/control-plane/internal/core/services/coverage_additional_test.go index 0bd291d59..c5f50a12d 100644 --- a/control-plane/internal/core/services/coverage_additional_test.go +++ b/control-plane/internal/core/services/coverage_additional_test.go @@ -1,6 +1,7 @@ package services import ( + "errors" "fmt" "net" "net/http" @@ -16,7 +17,6 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/packages" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" ) func TestResolveServerURL(t *testing.T) { @@ -140,24 +140,19 @@ func TestAgentServiceWaitForAgentNode(t *testing.T) { func TestAgentServiceUpdateRuntimeInfo(t *testing.T) { home := t.TempDir() - registryPath := filepath.Join(home, "installed.yaml") registry := &packages.InstallationRegistry{ Installed: map[string]packages.InstalledPackage{ "agent": {Name: "agent", Status: "stopped"}, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(registryPath, data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) - service := &DefaultAgentService{agentfieldHome: home} + service := &DefaultAgentService{registryStorage: registryStorage, agentfieldHome: home} require.NoError(t, service.updateRuntimeInfo("agent", 8123, 4567)) - updatedData, err := os.ReadFile(registryPath) + updated, err := loadPackagesRegistry(registryStorage) require.NoError(t, err) - - var updated packages.InstallationRegistry - require.NoError(t, yaml.Unmarshal(updatedData, &updated)) assert.Equal(t, "running", updated.Installed["agent"].Status) require.NotNil(t, updated.Installed["agent"].Runtime.Port) require.NotNil(t, updated.Installed["agent"].Runtime.PID) @@ -167,7 +162,7 @@ func TestAgentServiceUpdateRuntimeInfo(t *testing.T) { } func TestPackageServiceHelpers(t *testing.T) { - service := &DefaultPackageService{agentfieldHome: t.TempDir()} + service := NewPackageService(newMockPackageRegistryStorage(), newMockFileSystemAdapter(), t.TempDir()).(*DefaultPackageService) t.Run("parse metadata default main", func(t *testing.T) { dir := t.TempDir() @@ -224,13 +219,15 @@ func TestPackageServiceHelpers(t *testing.T) { "present": {Name: "present"}, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(service.agentfieldHome, "installed.yaml"), data, 0o644)) - assert.True(t, service.isPackageInstalled("present")) + registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) + localService := NewPackageService(registryStorage, newMockFileSystemAdapter(), t.TempDir()).(*DefaultPackageService) + assert.True(t, localService.isPackageInstalled("present")) - require.NoError(t, os.WriteFile(filepath.Join(service.agentfieldHome, "installed.yaml"), []byte("invalid: ["), 0o644)) - assert.False(t, service.isPackageInstalled("present")) + registryStorage.loadFunc = func() (*packages.InstallationRegistry, error) { + return nil, errors.New("invalid registry") + } + assert.False(t, localService.isPackageInstalled("present")) }) t.Run("copy package", func(t *testing.T) { @@ -288,7 +285,8 @@ exit 0 t.Run("update registry writes package metadata", func(t *testing.T) { home := t.TempDir() - localService := &DefaultPackageService{agentfieldHome: home} + registryStorage := newMockPackageRegistryStorage() + localService := NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) metadata := &packages.PackageMetadata{ Name: "registry-agent", Version: "2.0.0", @@ -299,10 +297,8 @@ exit 0 require.NoError(t, localService.updateRegistry(metadata, sourcePath, destPath)) - data, err := os.ReadFile(filepath.Join(home, "installed.yaml")) + registry, err := loadPackagesRegistry(registryStorage) require.NoError(t, err) - var registry packages.InstallationRegistry - require.NoError(t, yaml.Unmarshal(data, ®istry)) assert.Equal(t, "registry-agent", registry.Installed["registry-agent"].Name) assert.Equal(t, destPath, registry.Installed["registry-agent"].Path) assert.Equal(t, filepath.Join(home, "logs", "registry-agent.log"), registry.Installed["registry-agent"].Runtime.LogFile) @@ -310,7 +306,8 @@ exit 0 t.Run("uninstall package success", func(t *testing.T) { home := t.TempDir() - localService := &DefaultPackageService{agentfieldHome: home} + registryStorage := newMockPackageRegistryStorage() + localService := NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) packagePath := filepath.Join(home, "packages", "remove-me") logPath := filepath.Join(home, "logs", "remove-me.log") require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0o755)) @@ -329,12 +326,10 @@ exit 0 }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + seedPackageMockRegistry(registryStorage, registry) require.NoError(t, localService.uninstallPackage("remove-me", false)) - _, err = os.Stat(packagePath) + _, err := os.Stat(packagePath) assert.True(t, os.IsNotExist(err)) _, err = os.Stat(logPath) assert.True(t, os.IsNotExist(err)) @@ -448,9 +443,8 @@ func TestAgentServiceRunStopAndStatusWithLiveProcess(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) server, port := startLocalServerOnFreePort(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -478,17 +472,15 @@ func TestAgentServiceRunStopAndStatusWithLiveProcess(t *testing.T) { return port, nil } - service := NewAgentService(processManager, portManager, newMockRegistryStorage(), newMockAgentClient(), home).(*DefaultAgentService) + service := NewAgentService(processManager, portManager, registryStorage, newMockAgentClient(), home).(*DefaultAgentService) runningAgent, err := service.RunAgent("run-agent", domain.RunOptions{}) require.NoError(t, err) assert.Equal(t, "run-agent", runningAgent.Name) assert.Equal(t, 4242, runningAgent.PID) assert.Equal(t, port, runningAgent.Port) - updatedData, err := os.ReadFile(filepath.Join(home, "installed.yaml")) + updated, err := loadPackagesRegistry(registryStorage) require.NoError(t, err) - var updated packages.InstallationRegistry - require.NoError(t, yaml.Unmarshal(updatedData, &updated)) assert.Equal(t, "running", updated.Installed["run-agent"].Status) }) @@ -522,20 +514,17 @@ func TestAgentServiceRunStopAndStatusWithLiveProcess(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), nil, home).(*DefaultAgentService) + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, nil, home).(*DefaultAgentService) require.NoError(t, service.StopAgent("stop-agent")) waitErr := cmd.Wait() assert.Error(t, waitErr) - updatedData, err := os.ReadFile(filepath.Join(home, "installed.yaml")) + updated, err := loadPackagesRegistry(registryStorage) require.NoError(t, err) - var updated packages.InstallationRegistry - require.NoError(t, yaml.Unmarshal(updatedData, &updated)) assert.Equal(t, "stopped", updated.Installed["stop-agent"].Status) assert.Nil(t, updated.Installed["stop-agent"].Runtime.PID) assert.Nil(t, updated.Installed["stop-agent"].Runtime.Port) @@ -571,11 +560,10 @@ func TestAgentServiceRunStopAndStatusWithLiveProcess(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), newMockAgentClient(), home).(*DefaultAgentService) + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, newMockAgentClient(), home).(*DefaultAgentService) status, err := service.GetAgentStatus("status-agent") require.NoError(t, err) assert.True(t, status.IsRunning) diff --git a/control-plane/internal/core/services/coverage_gap_test.go b/control-plane/internal/core/services/coverage_gap_test.go index a4a19972d..0e96eda1a 100644 --- a/control-plane/internal/core/services/coverage_gap_test.go +++ b/control-plane/internal/core/services/coverage_gap_test.go @@ -16,7 +16,6 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/packages" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" ) func TestCoverageGapInstallDependenciesErrors(t *testing.T) { @@ -99,34 +98,49 @@ chmod +x "$3/bin/pip" } func TestCoverageGapPackageServiceRegistryAndUninstall(t *testing.T) { - t.Run("update registry invalid yaml", func(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), []byte("installed: ["), 0o644)) + t.Run("update registry load failure", func(t *testing.T) { + registryStorage := newMockPackageRegistryStorage() + registryStorage.loadFunc = func() (*packages.InstallationRegistry, error) { + return nil, errors.New("invalid registry") + } - service := &DefaultPackageService{agentfieldHome: home} + service := &DefaultPackageService{ + registryStorage: registryStorage, + agentfieldHome: t.TempDir(), + } err := service.updateRegistry(&packages.PackageMetadata{Name: "pkg", Version: "1.0.0"}, "/src", "/dest") require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse registry") + assert.Contains(t, err.Error(), "failed to load registry") }) - t.Run("update registry directory creation fails", func(t *testing.T) { - blocker := filepath.Join(t.TempDir(), "blocker") - require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644)) + t.Run("update registry save failure", func(t *testing.T) { + registryStorage := newMockPackageRegistryStorage() + registryStorage.saveFunc = func(*packages.InstallationRegistry) error { + return errors.New("failed to write registry") + } - service := &DefaultPackageService{agentfieldHome: blocker} + service := &DefaultPackageService{ + registryStorage: registryStorage, + agentfieldHome: t.TempDir(), + } err := service.updateRegistry(&packages.PackageMetadata{Name: "pkg", Version: "1.0.0"}, "/src", "/dest") require.Error(t, err) - assert.Contains(t, err.Error(), "failed to create registry directory") + assert.Contains(t, err.Error(), "failed to save registry") }) - t.Run("load registry invalid yaml", func(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), []byte("installed: ["), 0o644)) + t.Run("load registry failure", func(t *testing.T) { + registryStorage := newMockPackageRegistryStorage() + registryStorage.loadFunc = func() (*packages.InstallationRegistry, error) { + return nil, errors.New("invalid registry") + } - service := &DefaultPackageService{agentfieldHome: home} - _, err := service.loadRegistryDirect() + service := &DefaultPackageService{ + registryStorage: registryStorage, + agentfieldHome: t.TempDir(), + } + _, err := loadPackagesRegistry(service.registryStorage) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse registry") + assert.Contains(t, err.Error(), "failed to load registry") }) t.Run("uninstall running package without force", func(t *testing.T) { @@ -136,18 +150,17 @@ func TestCoverageGapPackageServiceRegistryAndUninstall(t *testing.T) { "busy": {Name: "busy", Path: filepath.Join(home, "packages", "busy"), Status: "running"}, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) - service := &DefaultPackageService{agentfieldHome: home} - err = service.uninstallPackage("busy", false) + service := NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) + err := service.uninstallPackage("busy", false) require.Error(t, err) assert.Contains(t, err.Error(), "currently running") }) t.Run("uninstall missing package", func(t *testing.T) { - service := &DefaultPackageService{agentfieldHome: t.TempDir()} + service := NewPackageService(newMockPackageRegistryStorage(), newMockFileSystemAdapter(), t.TempDir()).(*DefaultPackageService) err := service.uninstallPackage("missing", false) require.Error(t, err) assert.Contains(t, err.Error(), "is not installed") @@ -156,13 +169,15 @@ func TestCoverageGapPackageServiceRegistryAndUninstall(t *testing.T) { func TestCoverageGapAgentServiceBranches(t *testing.T) { t.Run("update runtime info read error", func(t *testing.T) { - blocker := filepath.Join(t.TempDir(), "blocker") - require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644)) + registryStorage := newMockRegistryStorage() + registryStorage.loadRegistryFunc = func() (*domain.InstallationRegistry, error) { + return nil, errors.New("read failed") + } - service := &DefaultAgentService{agentfieldHome: blocker} + service := &DefaultAgentService{registryStorage: registryStorage, agentfieldHome: t.TempDir()} err := service.updateRuntimeInfo("agent", 8123, 44) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read registry") + assert.Contains(t, err.Error(), "failed to load registry") }) t.Run("reconcile running state without pid", func(t *testing.T) { @@ -204,9 +219,8 @@ func TestCoverageGapAgentServiceBranches(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) agentClient := &mockAgentClient{ shutdownFunc: func(ctx context.Context, nodeID string, graceful bool, timeoutSeconds int) (*interfaces.AgentShutdownResponse, error) { @@ -214,13 +228,11 @@ func TestCoverageGapAgentServiceBranches(t *testing.T) { }, } - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), agentClient, home).(*DefaultAgentService) + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, agentClient, home).(*DefaultAgentService) require.NoError(t, service.StopAgent("fallback-agent")) - updatedData, err := os.ReadFile(filepath.Join(home, "installed.yaml")) + updated, err := loadPackagesRegistry(registryStorage) require.NoError(t, err) - var updated packages.InstallationRegistry - require.NoError(t, yaml.Unmarshal(updatedData, &updated)) assert.Equal(t, "stopped", updated.Installed["fallback-agent"].Status) assert.Nil(t, updated.Installed["fallback-agent"].Runtime.PID) assert.Nil(t, updated.Installed["fallback-agent"].Runtime.Port) diff --git a/control-plane/internal/core/services/coverage_targeted_test.go b/control-plane/internal/core/services/coverage_targeted_test.go index 2505bd239..4d06a7697 100644 --- a/control-plane/internal/core/services/coverage_targeted_test.go +++ b/control-plane/internal/core/services/coverage_targeted_test.go @@ -4,6 +4,7 @@ package services import ( "context" + "errors" "fmt" "net" "net/http" @@ -18,7 +19,6 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/packages" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" ) func TestRunDevHelperProcess(t *testing.T) { @@ -165,22 +165,19 @@ func TestAgentServiceStopAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) agentClient := newMockAgentClient() agentClient.shutdownFunc = func(ctx context.Context, nodeID string, graceful bool, timeoutSeconds int) (*interfaces.AgentShutdownResponse, error) { return &interfaces.AgentShutdownResponse{Status: "shutting_down"}, nil } - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), agentClient, home).(*DefaultAgentService) + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, agentClient, home).(*DefaultAgentService) require.NoError(t, service.StopAgent("http-agent")) - updatedData, err := os.ReadFile(filepath.Join(home, "installed.yaml")) + updated, err := loadPackagesRegistry(registryStorage) require.NoError(t, err) - var updated packages.InstallationRegistry - require.NoError(t, yaml.Unmarshal(updatedData, &updated)) assert.Equal(t, "stopped", updated.Installed["http-agent"].Status) assert.Nil(t, updated.Installed["http-agent"].Runtime.PID) assert.Nil(t, updated.Installed["http-agent"].Runtime.Port) @@ -201,12 +198,11 @@ func TestAgentServiceStopAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), nil, home).(*DefaultAgentService) - err = service.StopAgent("missing-pid-agent") + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, nil, home).(*DefaultAgentService) + err := service.StopAgent("missing-pid-agent") require.Error(t, err) assert.Contains(t, err.Error(), "not running") }) @@ -236,12 +232,11 @@ func TestAgentServiceStopAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), nil, home).(*DefaultAgentService) - err = service.StopAgent("missing-port-agent") + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, nil, home).(*DefaultAgentService) + err := service.StopAgent("missing-port-agent") require.Error(t, err) assert.Contains(t, err.Error(), "no port found") }) @@ -256,12 +251,11 @@ func TestAgentServiceStopAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), nil, home).(*DefaultAgentService) - err = service.StopAgent("already-stopped") + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, nil, home).(*DefaultAgentService) + err := service.StopAgent("already-stopped") require.Error(t, err) assert.Contains(t, err.Error(), "not running") }) @@ -286,9 +280,8 @@ func TestAgentServiceStopAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) agentClient := newMockAgentClient() agentClient.shutdownFunc = func(ctx context.Context, nodeID string, graceful bool, timeoutSeconds int) (*interfaces.AgentShutdownResponse, error) { @@ -297,7 +290,7 @@ func TestAgentServiceStopAgentAdditionalCoverage(t *testing.T) { return nil, fmt.Errorf("shutdown unavailable") } - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), agentClient, home).(*DefaultAgentService) + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, agentClient, home).(*DefaultAgentService) require.NoError(t, service.StopAgent("finished-agent")) _, _ = cmd.Process.Wait() }) @@ -319,17 +312,14 @@ func TestPackageServiceAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(existing) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, existing) - service := &DefaultPackageService{agentfieldHome: home} + service := NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) require.NoError(t, service.installLocalPackage(sourcePath, true, false)) - updatedData, err := os.ReadFile(filepath.Join(home, "installed.yaml")) + updated, err := loadPackagesRegistry(registryStorage) require.NoError(t, err) - var updated packages.InstallationRegistry - require.NoError(t, yaml.Unmarshal(updatedData, &updated)) assert.Equal(t, filepath.Join(home, "packages", "force-package"), updated.Installed["force-package"].Path) }) @@ -340,7 +330,7 @@ func TestPackageServiceAdditionalCoverage(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(sourcePath, "main.py"), []byte("print('ok')\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(home, "packages"), []byte("blocker"), 0o644)) - service := &DefaultPackageService{agentfieldHome: home} + service := NewPackageService(newMockPackageRegistryStorage(), newMockFileSystemAdapter(), home).(*DefaultPackageService) err := service.installLocalPackage(sourcePath, false, false) require.Error(t, err) assert.Contains(t, err.Error(), "failed to copy package") @@ -366,24 +356,26 @@ func TestPackageServiceAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) - service := &DefaultPackageService{agentfieldHome: home} + service := NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) require.NoError(t, service.uninstallPackage("force-remove", true)) - _, err = os.Stat(packagePath) + _, err := os.Stat(packagePath) assert.True(t, os.IsNotExist(err)) }) t.Run("update registry with invalid existing yaml", func(t *testing.T) { home := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), []byte("installed: ["), 0o644)) + registryStorage := newMockPackageRegistryStorage() + registryStorage.loadFunc = func() (*packages.InstallationRegistry, error) { + return nil, errors.New("failed to parse registry") + } - service := &DefaultPackageService{agentfieldHome: home} + service := NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) err := service.updateRegistry(&packages.PackageMetadata{Name: "pkg", Version: "1.0.0"}, t.TempDir(), filepath.Join(home, "packages", "pkg")) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse registry") + assert.Contains(t, err.Error(), "failed to load registry") }) t.Run("update registry preserves existing packages", func(t *testing.T) { @@ -397,18 +389,15 @@ func TestPackageServiceAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(existing) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, existing) - service := &DefaultPackageService{agentfieldHome: home} - err = service.updateRegistry(&packages.PackageMetadata{Name: "new-package", Version: "2.0.0"}, t.TempDir(), filepath.Join(home, "packages", "new-package")) + service := NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) + err := service.updateRegistry(&packages.PackageMetadata{Name: "new-package", Version: "2.0.0"}, t.TempDir(), filepath.Join(home, "packages", "new-package")) require.NoError(t, err) - updatedData, err := os.ReadFile(filepath.Join(home, "installed.yaml")) + updated, err := loadPackagesRegistry(registryStorage) require.NoError(t, err) - var updated packages.InstallationRegistry - require.NoError(t, yaml.Unmarshal(updatedData, &updated)) _, existingOK := updated.Installed["existing"] _, newOK := updated.Installed["new-package"] assert.True(t, existingOK) @@ -416,13 +405,18 @@ func TestPackageServiceAdditionalCoverage(t *testing.T) { }) t.Run("save registry write error", func(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), []byte("blocking file"), 0o644)) + registryStorage := newMockPackageRegistryStorage() + registryStorage.saveFunc = func(*packages.InstallationRegistry) error { + return errors.New("failed to write registry") + } - service := &DefaultPackageService{agentfieldHome: filepath.Join(home, "installed.yaml")} - err := service.saveRegistry(&packages.InstallationRegistry{Installed: map[string]packages.InstalledPackage{}}) + service := &DefaultPackageService{ + registryStorage: registryStorage, + agentfieldHome: t.TempDir(), + } + err := savePackagesRegistry(service.registryStorage, &packages.InstallationRegistry{Installed: map[string]packages.InstalledPackage{}}) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to write registry") + assert.Contains(t, err.Error(), "failed to save registry") }) t.Run("copy file missing source", func(t *testing.T) { @@ -479,11 +473,10 @@ func TestPackageServiceAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) - service := &DefaultPackageService{agentfieldHome: home} + service := NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) require.NoError(t, service.uninstallPackage("live-remove", true)) waitErr := cmd.Wait() assert.Error(t, waitErr) @@ -552,17 +545,16 @@ func TestAgentServiceRunAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) portManager := newMockPortManager() portManager.findFreePortFunc = func(startPort int) (int, error) { return 0, fmt.Errorf("no ports") } - service := NewAgentService(newMockProcessManager(), portManager, newMockRegistryStorage(), newMockAgentClient(), home).(*DefaultAgentService) - _, err = service.RunAgent("alloc-agent", domain.RunOptions{}) + service := NewAgentService(newMockProcessManager(), portManager, registryStorage, newMockAgentClient(), home).(*DefaultAgentService) + _, err := service.RunAgent("alloc-agent", domain.RunOptions{}) require.Error(t, err) assert.Contains(t, err.Error(), "failed to allocate port") }) @@ -578,17 +570,16 @@ func TestAgentServiceRunAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) processManager := newMockProcessManager() processManager.startFunc = func(config interfaces.ProcessConfig) (int, error) { return 0, fmt.Errorf("boom") } - service := NewAgentService(processManager, newMockPortManager(), newMockRegistryStorage(), newMockAgentClient(), home).(*DefaultAgentService) - _, err = service.RunAgent("start-agent", domain.RunOptions{Port: 8143}) + service := NewAgentService(processManager, newMockPortManager(), registryStorage, newMockAgentClient(), home).(*DefaultAgentService) + _, err := service.RunAgent("start-agent", domain.RunOptions{Port: 8143}) require.Error(t, err) assert.Contains(t, err.Error(), "failed to start agent node") }) @@ -621,12 +612,11 @@ func TestAgentServiceRunAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), data, 0o644)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) - service := NewAgentService(newMockProcessManager(), newMockPortManager(), newMockRegistryStorage(), newMockAgentClient(), home).(*DefaultAgentService) - _, err = service.RunAgent("running-agent", domain.RunOptions{}) + service := NewAgentService(newMockProcessManager(), newMockPortManager(), registryStorage, newMockAgentClient(), home).(*DefaultAgentService) + _, err := service.RunAgent("running-agent", domain.RunOptions{}) require.Error(t, err) assert.Contains(t, err.Error(), "already running") }) @@ -646,10 +636,11 @@ func TestAgentServiceRunAgentAdditionalCoverage(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - registryPath := filepath.Join(home, "installed.yaml") - require.NoError(t, os.WriteFile(registryPath, data, 0o444)) + registryStorage := newMockRegistryStorage() + seedMockRegistry(registryStorage, registry) + registryStorage.saveRegistryFunc = func(*domain.InstallationRegistry) error { + return errors.New("failed to write registry") + } server, port := startLocalServerOnFreePort(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -670,38 +661,44 @@ func TestAgentServiceRunAgentAdditionalCoverage(t *testing.T) { return 5151, nil } - service := NewAgentService(processManager, newMockPortManager(), newMockRegistryStorage(), newMockAgentClient(), home).(*DefaultAgentService) - _, err = service.RunAgent("update-agent", domain.RunOptions{Port: port}) + service := NewAgentService(processManager, newMockPortManager(), registryStorage, newMockAgentClient(), home).(*DefaultAgentService) + _, err := service.RunAgent("update-agent", domain.RunOptions{Port: port}) require.Error(t, err) assert.Contains(t, err.Error(), "failed to update runtime info") }) } func TestAgentServiceRegistryHelpersAdditionalCoverage(t *testing.T) { - t.Run("load registry invalid yaml", func(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), []byte("installed: ["), 0o644)) - service := &DefaultAgentService{agentfieldHome: home} - _, err := service.loadRegistryDirect() + t.Run("load registry failure", func(t *testing.T) { + registryStorage := newMockRegistryStorage() + registryStorage.loadRegistryFunc = func() (*domain.InstallationRegistry, error) { + return nil, errors.New("invalid registry") + } + service := &DefaultAgentService{registryStorage: registryStorage, agentfieldHome: t.TempDir()} + _, err := loadPackagesRegistry(service.registryStorage) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse registry") + assert.Contains(t, err.Error(), "failed to load registry") }) t.Run("save registry write error", func(t *testing.T) { - home := t.TempDir() - blocker := filepath.Join(home, "blocker") - require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644)) - service := &DefaultAgentService{agentfieldHome: blocker} - err := service.saveRegistryDirect(&packages.InstallationRegistry{Installed: map[string]packages.InstalledPackage{}}) + registryStorage := newMockRegistryStorage() + registryStorage.saveRegistryFunc = func(*domain.InstallationRegistry) error { + return errors.New("failed to write registry") + } + service := &DefaultAgentService{registryStorage: registryStorage, agentfieldHome: t.TempDir()} + err := savePackagesRegistry(service.registryStorage, &packages.InstallationRegistry{Installed: map[string]packages.InstalledPackage{}}) require.Error(t, err) + assert.Contains(t, err.Error(), "failed to save registry") }) - t.Run("update runtime info invalid yaml", func(t *testing.T) { - home := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(home, "installed.yaml"), []byte("installed: ["), 0o644)) - service := &DefaultAgentService{agentfieldHome: home} + t.Run("update runtime info load failure", func(t *testing.T) { + registryStorage := newMockRegistryStorage() + registryStorage.loadRegistryFunc = func() (*domain.InstallationRegistry, error) { + return nil, errors.New("invalid registry") + } + service := &DefaultAgentService{registryStorage: registryStorage, agentfieldHome: t.TempDir()} err := service.updateRuntimeInfo("agent", 8145, 5152) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse registry") + assert.Contains(t, err.Error(), "failed to load registry") }) } diff --git a/control-plane/internal/core/services/package_service.go b/control-plane/internal/core/services/package_service.go index f4de9b403..9131f0539 100644 --- a/control-plane/internal/core/services/package_service.go +++ b/control-plane/internal/core/services/package_service.go @@ -121,7 +121,7 @@ func (ps *DefaultPackageService) uninstallPackage(packageName string, force bool fmt.Printf("Uninstalling package: %s\n", packageName) // 1. Load registry - registry, err := ps.loadRegistryDirect() + registry, err := loadPackagesRegistry(ps.registryStorage) if err != nil { return fmt.Errorf("failed to load registry: %w", err) } @@ -159,7 +159,7 @@ func (ps *DefaultPackageService) uninstallPackage(packageName string, force bool // 7. Update registry delete(registry.Installed, packageName) - if err := ps.saveRegistry(registry); err != nil { + if err := savePackagesRegistry(ps.registryStorage, registry); err != nil { return fmt.Errorf("failed to update registry: %w", err) } @@ -186,27 +186,11 @@ func (ps *DefaultPackageService) stopAgentNode(agentNode *packages.InstalledPack return nil } -// saveRegistry saves the installation registry -func (ps *DefaultPackageService) saveRegistry(registry *packages.InstallationRegistry) error { - registryPath := filepath.Join(ps.agentfieldHome, "installed.yaml") - - data, err := yaml.Marshal(registry) - if err != nil { - return fmt.Errorf("failed to marshal registry: %w", err) - } - - if err := os.WriteFile(registryPath, data, 0644); err != nil { - return fmt.Errorf("failed to write registry: %w", err) - } - - return nil -} - // ListInstalledPackages returns a list of all installed packages func (ps *DefaultPackageService) ListInstalledPackages() ([]domain.InstalledPackage, error) { // Load registry using existing packages logic for now // TODO: Eventually migrate to use registryStorage interface - registry, err := ps.loadRegistryDirect() + registry, err := loadPackagesRegistry(ps.registryStorage) if err != nil { return nil, err } @@ -222,7 +206,7 @@ func (ps *DefaultPackageService) ListInstalledPackages() ([]domain.InstalledPack // GetPackageInfo returns information about a specific installed package func (ps *DefaultPackageService) GetPackageInfo(name string) (*domain.InstalledPackage, error) { // Load registry using existing packages logic for now - registry, err := ps.loadRegistryDirect() + registry, err := loadPackagesRegistry(ps.registryStorage) if err != nil { return nil, err } @@ -236,24 +220,6 @@ func (ps *DefaultPackageService) GetPackageInfo(name string) (*domain.InstalledP return &domainPackage, nil } -// loadRegistryDirect loads the registry using direct file system access -// TODO: Eventually replace with registryStorage interface usage -func (ps *DefaultPackageService) loadRegistryDirect() (*packages.InstallationRegistry, error) { - registryPath := filepath.Join(ps.agentfieldHome, "installed.yaml") - - registry := &packages.InstallationRegistry{ - Installed: make(map[string]packages.InstalledPackage), - } - - if data, err := os.ReadFile(registryPath); err == nil { - if err := yaml.Unmarshal(data, registry); err != nil { - return nil, fmt.Errorf("failed to parse registry: %w", err) - } - } - - return registry, nil -} - // convertToDomainPackage converts packages.InstalledPackage to domain.InstalledPackage func (ps *DefaultPackageService) convertToDomainPackage(pkg packages.InstalledPackage) domain.InstalledPackage { // Parse the installed_at time @@ -422,15 +388,9 @@ func (ps *DefaultPackageService) parsePackageMetadata(sourcePath string) (*packa // isPackageInstalled checks if a package is already installed func (ps *DefaultPackageService) isPackageInstalled(packageName string) bool { - registryPath := filepath.Join(ps.agentfieldHome, "installed.yaml") - registry := &packages.InstallationRegistry{ - Installed: make(map[string]packages.InstalledPackage), - } - - if data, err := os.ReadFile(registryPath); err == nil { - if err := yaml.Unmarshal(data, registry); err != nil { - return false - } + registry, err := loadPackagesRegistry(ps.registryStorage) + if err != nil { + return false } _, exists := registry.Installed[packageName] @@ -557,20 +517,11 @@ func (ps *DefaultPackageService) hasRequirementsFile(packagePath string) bool { // updateRegistry updates the installation registry with the new package func (ps *DefaultPackageService) updateRegistry(metadata *packages.PackageMetadata, sourcePath, destPath string) error { - registryPath := filepath.Join(ps.agentfieldHome, "installed.yaml") - - // Load existing registry or create new one - registry := &packages.InstallationRegistry{ - Installed: make(map[string]packages.InstalledPackage), - } - - if data, err := os.ReadFile(registryPath); err == nil { - if err := yaml.Unmarshal(data, registry); err != nil { - return fmt.Errorf("failed to parse registry: %w", err) - } + registry, err := loadPackagesRegistry(ps.registryStorage) + if err != nil { + return err } - // Add/update package entry registry.Installed[metadata.Name] = packages.InstalledPackage{ Name: metadata.Name, Version: metadata.Version, @@ -588,22 +539,7 @@ func (ps *DefaultPackageService) updateRegistry(metadata *packages.PackageMetada }, } - // Save registry - data, err := yaml.Marshal(registry) - if err != nil { - return fmt.Errorf("failed to marshal registry: %w", err) - } - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(registryPath), 0755); err != nil { - return fmt.Errorf("failed to create registry directory: %w", err) - } - - if err := os.WriteFile(registryPath, data, 0644); err != nil { - return fmt.Errorf("failed to write registry: %w", err) - } - - return nil + return savePackagesRegistry(ps.registryStorage, registry) } // checkEnvironmentVariables checks for required environment variables and provides setup guidance diff --git a/control-plane/internal/core/services/package_service_test.go b/control-plane/internal/core/services/package_service_test.go index e61eb691f..5fb8ec937 100644 --- a/control-plane/internal/core/services/package_service_test.go +++ b/control-plane/internal/core/services/package_service_test.go @@ -11,7 +11,6 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/packages" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" ) // Mock RegistryStorage for package service testing @@ -31,6 +30,18 @@ func newMockPackageRegistryStorage() *mockPackageRegistryStorage { } } +func seedPackageMockRegistry(storage *mockPackageRegistryStorage, registry *packages.InstallationRegistry) { + storage.registry = registry +} + +func newTestPackageService(home string, registry *packages.InstallationRegistry) *DefaultPackageService { + registryStorage := newMockPackageRegistryStorage() + if registry != nil { + seedPackageMockRegistry(registryStorage, registry) + } + return NewPackageService(registryStorage, newMockFileSystemAdapter(), home).(*DefaultPackageService) +} + func (m *mockPackageRegistryStorage) LoadRegistry() (*domain.InstallationRegistry, error) { // Convert packages.InstallationRegistry to domain.InstallationRegistry if m.loadFunc != nil { @@ -38,18 +49,18 @@ func (m *mockPackageRegistryStorage) LoadRegistry() (*domain.InstallationRegistr if err != nil { return nil, err } - return convertToDomainRegistry(pkgReg), nil + return packagesRegistryToDomain(pkgReg), nil } - return convertToDomainRegistry(m.registry), nil + return packagesRegistryToDomain(m.registry), nil } func (m *mockPackageRegistryStorage) SaveRegistry(registry *domain.InstallationRegistry) error { // Convert domain.InstallationRegistry to packages.InstallationRegistry if m.saveFunc != nil { - pkgReg := convertToPackagesRegistry(registry) + pkgReg := domainRegistryToPackages(registry) return m.saveFunc(pkgReg) } - m.registry = convertToPackagesRegistry(registry) + m.registry = domainRegistryToPackages(registry) return nil } @@ -69,52 +80,13 @@ func (m *mockPackageRegistryStorage) SavePackage(name string, pkg *domain.Instal return nil } -// Helper functions to convert between domain and packages types -func convertToDomainRegistry(pkgReg *packages.InstallationRegistry) *domain.InstallationRegistry { - domainReg := &domain.InstallationRegistry{ - Installed: make(map[string]domain.InstalledPackage), - } - for name, pkg := range pkgReg.Installed { - domainReg.Installed[name] = *convertToDomainPackage(&pkg) - } - return domainReg -} - -func convertToPackagesRegistry(domainReg *domain.InstallationRegistry) *packages.InstallationRegistry { - pkgReg := &packages.InstallationRegistry{ - Installed: make(map[string]packages.InstalledPackage), - } - for name, pkg := range domainReg.Installed { - pkgReg.Installed[name] = convertToPackagesPackage(name, &pkg) - } - return pkgReg -} - func convertToDomainPackage(pkg *packages.InstalledPackage) *domain.InstalledPackage { - var installedAt time.Time - if pkg.InstalledAt != "" { - if parsed, err := time.Parse(time.RFC3339, pkg.InstalledAt); err == nil { - installedAt = parsed - } - } - return &domain.InstalledPackage{ - Name: pkg.Name, - Version: pkg.Version, - Path: pkg.Path, - Environment: make(map[string]string), // packages doesn't store this - InstalledAt: installedAt, - } + domainPkg := packagesPackageToDomain(pkg.Name, *pkg) + return &domainPkg } func convertToPackagesPackage(name string, pkg *domain.InstalledPackage) packages.InstalledPackage { - return packages.InstalledPackage{ - Name: name, - Version: pkg.Version, - Path: pkg.Path, - InstalledAt: pkg.InstalledAt.Format(time.RFC3339), - Status: "stopped", - Runtime: packages.RuntimeInfo{}, - } + return domainPackageToPackages(name, *pkg) } func TestNewPackageService(t *testing.T) { @@ -228,8 +200,6 @@ main: main.py mainPyPath := filepath.Join(sourcePath, "main.py") require.NoError(t, os.WriteFile(mainPyPath, []byte("# Test package"), 0644)) - // Create registry with package already installed - registryPath := filepath.Join(agentfieldHome, "installed.yaml") registry := &packages.InstallationRegistry{ Installed: map[string]packages.InstalledPackage{ "test-package": { @@ -240,12 +210,9 @@ main: main.py }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(registryPath), 0755)) - require.NoError(t, os.WriteFile(registryPath, data, 0644)) registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) fileSystem := newMockFileSystemAdapter() service := NewPackageService(registryStorage, fileSystem, agentfieldHome).(*DefaultPackageService) @@ -255,7 +222,7 @@ main: main.py Verbose: false, } - err = service.InstallPackage(sourcePath, options) + err := service.InstallPackage(sourcePath, options) assert.Error(t, err) assert.Contains(t, err.Error(), "already installed") } @@ -266,8 +233,6 @@ func TestUninstallPackage_Success(t *testing.T) { packagePath := filepath.Join(agentfieldHome, "packages", "test-package") require.NoError(t, os.MkdirAll(packagePath, 0755)) - // Create registry with package installed - registryPath := filepath.Join(agentfieldHome, "installed.yaml") registry := &packages.InstallationRegistry{ Installed: map[string]packages.InstalledPackage{ "test-package": { @@ -281,17 +246,14 @@ func TestUninstallPackage_Success(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(registryPath), 0755)) - require.NoError(t, os.WriteFile(registryPath, data, 0644)) registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) fileSystem := newMockFileSystemAdapter() service := NewPackageService(registryStorage, fileSystem, agentfieldHome).(*DefaultPackageService) - err = service.UninstallPackage("test-package") + err := service.UninstallPackage("test-package") require.NoError(t, err) // Verify package directory is removed @@ -320,8 +282,6 @@ func TestUninstallPackage_Running(t *testing.T) { require.NoError(t, os.MkdirAll(packagePath, 0755)) pid := 12345 - // Create registry with package running - registryPath := filepath.Join(agentfieldHome, "installed.yaml") registry := &packages.InstallationRegistry{ Installed: map[string]packages.InstalledPackage{ "test-package": { @@ -336,17 +296,14 @@ func TestUninstallPackage_Running(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(registryPath), 0755)) - require.NoError(t, os.WriteFile(registryPath, data, 0644)) registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) fileSystem := newMockFileSystemAdapter() service := NewPackageService(registryStorage, fileSystem, agentfieldHome).(*DefaultPackageService) - err = service.UninstallPackage("test-package") + err := service.UninstallPackage("test-package") // Should fail because package is running assert.Error(t, err) assert.Contains(t, err.Error(), "currently running") @@ -356,8 +313,6 @@ func TestListInstalledPackages(t *testing.T) { tmpDir := t.TempDir() agentfieldHome := tmpDir - // Create registry with packages - registryPath := filepath.Join(agentfieldHome, "installed.yaml") installedAt := time.Now().Format(time.RFC3339) registry := &packages.InstallationRegistry{ Installed: map[string]packages.InstalledPackage{ @@ -375,12 +330,9 @@ func TestListInstalledPackages(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(registryPath), 0755)) - require.NoError(t, os.WriteFile(registryPath, data, 0644)) registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) fileSystem := newMockFileSystemAdapter() service := NewPackageService(registryStorage, fileSystem, agentfieldHome).(*DefaultPackageService) @@ -394,8 +346,6 @@ func TestGetPackageInfo_Success(t *testing.T) { tmpDir := t.TempDir() agentfieldHome := tmpDir - // Create registry with package - registryPath := filepath.Join(agentfieldHome, "installed.yaml") installedAt := time.Now().Format(time.RFC3339) registry := &packages.InstallationRegistry{ Installed: map[string]packages.InstalledPackage{ @@ -407,12 +357,9 @@ func TestGetPackageInfo_Success(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(registryPath), 0755)) - require.NoError(t, os.WriteFile(registryPath, data, 0644)) registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) fileSystem := newMockFileSystemAdapter() service := NewPackageService(registryStorage, fileSystem, agentfieldHome).(*DefaultPackageService) @@ -545,7 +492,6 @@ func TestIsPackageInstalled_Installed(t *testing.T) { tmpDir := t.TempDir() agentfieldHome := tmpDir - registryPath := filepath.Join(agentfieldHome, "installed.yaml") registry := &packages.InstallationRegistry{ Installed: map[string]packages.InstalledPackage{ "test-package": { @@ -554,12 +500,9 @@ func TestIsPackageInstalled_Installed(t *testing.T) { }, }, } - data, err := yaml.Marshal(registry) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(registryPath), 0755)) - require.NoError(t, os.WriteFile(registryPath, data, 0644)) registryStorage := newMockPackageRegistryStorage() + seedPackageMockRegistry(registryStorage, registry) fileSystem := newMockFileSystemAdapter() service := NewPackageService(registryStorage, fileSystem, agentfieldHome).(*DefaultPackageService) diff --git a/control-plane/internal/core/services/registry_bridge.go b/control-plane/internal/core/services/registry_bridge.go new file mode 100644 index 000000000..40625d138 --- /dev/null +++ b/control-plane/internal/core/services/registry_bridge.go @@ -0,0 +1,129 @@ +package services + +import ( + "fmt" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/core/domain" + "github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces" + "github.com/Agent-Field/agentfield/control-plane/internal/packages" +) + +func loadPackagesRegistry(storage interfaces.RegistryStorage) (*packages.InstallationRegistry, error) { + if storage == nil { + return &packages.InstallationRegistry{ + Installed: make(map[string]packages.InstalledPackage), + }, nil + } + + registry, err := storage.LoadRegistry() + if err != nil { + return nil, fmt.Errorf("failed to load registry: %w", err) + } + + return domainRegistryToPackages(registry), nil +} + +func savePackagesRegistry(storage interfaces.RegistryStorage, registry *packages.InstallationRegistry) error { + if storage == nil { + return fmt.Errorf("registry storage is not configured") + } + if registry == nil { + registry = &packages.InstallationRegistry{Installed: make(map[string]packages.InstalledPackage)} + } + + if err := storage.SaveRegistry(packagesRegistryToDomain(registry)); err != nil { + return fmt.Errorf("failed to save registry: %w", err) + } + + return nil +} + +func domainRegistryToPackages(registry *domain.InstallationRegistry) *packages.InstallationRegistry { + pkgReg := &packages.InstallationRegistry{ + Installed: make(map[string]packages.InstalledPackage), + } + if registry == nil { + return pkgReg + } + + for name, pkg := range registry.Installed { + pkgReg.Installed[name] = domainPackageToPackages(name, pkg) + } + + return pkgReg +} + +func packagesRegistryToDomain(registry *packages.InstallationRegistry) *domain.InstallationRegistry { + domainReg := &domain.InstallationRegistry{ + Installed: make(map[string]domain.InstalledPackage), + } + if registry == nil { + return domainReg + } + + for name, pkg := range registry.Installed { + domainReg.Installed[name] = packagesPackageToDomain(name, pkg) + } + + return domainReg +} + +func domainPackageToPackages(name string, pkg domain.InstalledPackage) packages.InstalledPackage { + if name == "" { + name = pkg.Name + } + + installedAt := "" + if !pkg.InstalledAt.IsZero() { + installedAt = pkg.InstalledAt.UTC().Format(time.RFC3339) + } + + return packages.InstalledPackage{ + Name: pkg.Name, + Version: pkg.Version, + Description: pkg.Description, + Path: pkg.Path, + Source: pkg.Source, + SourcePath: pkg.SourcePath, + InstalledAt: installedAt, + Status: pkg.Status, + Runtime: packages.RuntimeInfo{ + Port: pkg.Runtime.Port, + PID: pkg.Runtime.PID, + StartedAt: pkg.Runtime.StartedAt, + LogFile: pkg.Runtime.LogFile, + }, + } +} + +func packagesPackageToDomain(name string, pkg packages.InstalledPackage) domain.InstalledPackage { + if name == "" { + name = pkg.Name + } + + var installedAt time.Time + if pkg.InstalledAt != "" { + if parsed, err := time.Parse(time.RFC3339, pkg.InstalledAt); err == nil { + installedAt = parsed + } + } + + return domain.InstalledPackage{ + Name: name, + Version: pkg.Version, + Description: pkg.Description, + Path: pkg.Path, + Source: pkg.Source, + SourcePath: pkg.SourcePath, + Status: pkg.Status, + Environment: make(map[string]string), + InstalledAt: installedAt, + Runtime: domain.PackageRuntime{ + Port: pkg.Runtime.Port, + PID: pkg.Runtime.PID, + StartedAt: pkg.Runtime.StartedAt, + LogFile: pkg.Runtime.LogFile, + }, + } +} diff --git a/control-plane/internal/handlers/agentic/status_test.go b/control-plane/internal/handlers/agentic/status_test.go index 33f7d3cba..b4bdc73d6 100644 --- a/control-plane/internal/handlers/agentic/status_test.go +++ b/control-plane/internal/handlers/agentic/status_test.go @@ -127,8 +127,8 @@ func (m *mockStatusStorage) PruneExecutionLogEntries(ctx context.Context, execut func (m *mockStatusStorage) ListWorkflowExecutionEvents(ctx context.Context, executionID string, afterSeq *int64, limit int) ([]*types.WorkflowExecutionEvent, error) { return nil, nil } -func (m *mockStatusStorage) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, error) { - return 0, nil +func (m *mockStatusStorage) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, []string, error) { + return 0, nil, nil } func (m *mockStatusStorage) MarkStaleExecutions(ctx context.Context, staleAfter time.Duration, limit int) (int, error) { return 0, nil diff --git a/control-plane/internal/handlers/config_storage_test.go b/control-plane/internal/handlers/config_storage_test.go index 858b47523..f4be61b55 100644 --- a/control-plane/internal/handlers/config_storage_test.go +++ b/control-plane/internal/handlers/config_storage_test.go @@ -140,8 +140,8 @@ func (m *configStorageMock) PruneExecutionLogEntries(ctx context.Context, execut func (m *configStorageMock) ListWorkflowExecutionEvents(ctx context.Context, executionID string, afterSeq *int64, limit int) ([]*types.WorkflowExecutionEvent, error) { return nil, nil } -func (m *configStorageMock) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, error) { - return 0, nil +func (m *configStorageMock) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, []string, error) { + return 0, nil, nil } func (m *configStorageMock) MarkStaleExecutions(ctx context.Context, staleAfter time.Duration, limit int) (int, error) { return 0, nil diff --git a/control-plane/internal/handlers/connector/handlers_test.go b/control-plane/internal/handlers/connector/handlers_test.go index 195398819..73545c96e 100644 --- a/control-plane/internal/handlers/connector/handlers_test.go +++ b/control-plane/internal/handlers/connector/handlers_test.go @@ -195,8 +195,8 @@ func (m *mockStorage) PruneExecutionLogEntries(ctx context.Context, executionID func (m *mockStorage) ListWorkflowExecutionEvents(ctx context.Context, executionID string, afterSeq *int64, limit int) ([]*types.WorkflowExecutionEvent, error) { return nil, nil } -func (m *mockStorage) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, error) { - return 0, nil +func (m *mockStorage) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, []string, error) { + return 0, nil, nil } func (m *mockStorage) MarkStaleExecutions(ctx context.Context, staleAfter time.Duration, limit int) (int, error) { return 0, nil diff --git a/control-plane/internal/handlers/coverage_additional_test.go b/control-plane/internal/handlers/coverage_additional_test.go index ec8a1ddb2..0af18d24b 100644 --- a/control-plane/internal/handlers/coverage_additional_test.go +++ b/control-plane/internal/handlers/coverage_additional_test.go @@ -657,21 +657,6 @@ func TestNodeRESTHandlers_SuccessPaths(t *testing.T) { assert.Contains(t, resp.Body.String(), `"lease_seconds":60`) }) - t.Run("claim actions defaults wait seconds", func(t *testing.T) { - store := &nodeRESTStorageStub{agent: agent} - router := gin.New() - router.POST("/actions/claim", ClaimActionsHandler(store, presence, time.Minute)) - - req := httptest.NewRequest(http.MethodPost, "/actions/claim", strings.NewReader(`{"node_id":"node-1","max_items":0,"wait_seconds":0}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code) - assert.Equal(t, "node-1", store.lastHeartbeatID) - assert.Contains(t, resp.Body.String(), `"next_poll_after":5`) - }) - t.Run("shutdown uses version lookup and acknowledges", func(t *testing.T) { store := &nodeRESTStorageStub{versionedAgent: agent} router := gin.New() @@ -767,29 +752,6 @@ func TestNodeHandlers_BasicCoverage(t *testing.T) { require.Equal(t, http.StatusOK, resp.Code) }) - t.Run("update lifecycle fallback success and pending approval rejection", func(t *testing.T) { - store := &nodeRESTStorageStub{agent: agent} - router := gin.New() - router.POST("/nodes/:node_id/lifecycle", UpdateLifecycleStatusHandler(store, nil, nil)) - - req := httptest.NewRequest(http.MethodPost, "/nodes/node-1/lifecycle", strings.NewReader(`{"lifecycle_status":"ready"}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - require.Equal(t, http.StatusOK, resp.Code) - require.NotNil(t, store.updatedLifecycle) - assert.Equal(t, types.AgentStatusReady, *store.updatedLifecycle) - - store = &nodeRESTStorageStub{agent: &types.AgentNode{ID: "node-1", LifecycleStatus: types.AgentStatusPendingApproval}} - router = gin.New() - router.POST("/nodes/:node_id/lifecycle", UpdateLifecycleStatusHandler(store, nil, nil)) - req = httptest.NewRequest(http.MethodPost, "/nodes/node-1/lifecycle", strings.NewReader(`{"lifecycle_status":"offline"}`)) - req.Header.Set("Content-Type", "application/json") - resp = httptest.NewRecorder() - router.ServeHTTP(resp, req) - require.Equal(t, http.StatusConflict, resp.Code) - }) - t.Run("status and refresh endpoints return service unavailable when manager missing", func(t *testing.T) { router := gin.New() router.GET("/nodes/:node_id/status", GetNodeStatusHandler(nil)) @@ -806,13 +768,11 @@ func TestNodeHandlers_BasicCoverage(t *testing.T) { require.Equal(t, http.StatusServiceUnavailable, resp.Code) }) - t.Run("bulk refresh and lifecycle handlers with nil manager", func(t *testing.T) { + t.Run("bulk refresh handlers with nil manager", func(t *testing.T) { store := &nodeRESTStorageStub{agent: agent, listAgents: []*types.AgentNode{agent}} router := gin.New() router.POST("/nodes/bulk-status", BulkNodeStatusHandler(nil, store)) router.POST("/nodes/status/refresh-all", RefreshAllNodeStatusHandler(nil, store)) - router.POST("/nodes/:node_id/start", StartNodeHandler(nil, store)) - router.POST("/nodes/:node_id/stop", StopNodeHandler(nil, store)) req := httptest.NewRequest(http.MethodPost, "/nodes/bulk-status", strings.NewReader(`{"node_ids":["node-1"]}`)) req.Header.Set("Content-Type", "application/json") @@ -824,16 +784,6 @@ func TestNodeHandlers_BasicCoverage(t *testing.T) { resp = httptest.NewRecorder() router.ServeHTTP(resp, req) require.Equal(t, http.StatusServiceUnavailable, resp.Code) - - req = httptest.NewRequest(http.MethodPost, "/nodes/node-1/start", nil) - resp = httptest.NewRecorder() - router.ServeHTTP(resp, req) - require.Equal(t, http.StatusServiceUnavailable, resp.Code) - - req = httptest.NewRequest(http.MethodPost, "/nodes/node-1/stop", nil) - resp = httptest.NewRecorder() - router.ServeHTTP(resp, req) - require.Equal(t, http.StatusServiceUnavailable, resp.Code) }) } diff --git a/control-plane/internal/handlers/coverage_handlers_90_test.go b/control-plane/internal/handlers/coverage_handlers_90_test.go index 579deea99..5a505cd7a 100644 --- a/control-plane/internal/handlers/coverage_handlers_90_test.go +++ b/control-plane/internal/handlers/coverage_handlers_90_test.go @@ -14,7 +14,6 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/events" "github.com/Agent-Field/agentfield/control-plane/internal/server/middleware" - "github.com/Agent-Field/agentfield/control-plane/internal/storage" "github.com/Agent-Field/agentfield/control-plane/pkg/types" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -57,17 +56,6 @@ func (s *didVCServiceStub) ListAgentTagVCs() ([]*types.AgentTagVCRecord, error) return []*types.AgentTagVCRecord{}, nil } -type persistWorkflowExecutionStore struct { - storage.StorageProvider - called bool - err error -} - -func (s *persistWorkflowExecutionStore) StoreWorkflowExecution(ctx context.Context, execution *types.WorkflowExecution) error { - s.called = true - return s.err -} - func TestWorkflowExecutionEventHelpersCoverage(t *testing.T) { now := time.Date(2026, 4, 8, 12, 0, 0, 0, time.UTC) parentExecutionID := "parent-exec" @@ -430,44 +418,9 @@ func TestDIDHandlersCoverageAdditional(t *testing.T) { }) } -func TestExecuteReasonerAndWebhookHelpersCoverage(t *testing.T) { +func TestWebhookHelpersCoverage(t *testing.T) { gin.SetMode(gin.TestMode) - t.Run("persist workflow execution tolerates success and error", func(t *testing.T) { - successStore := &persistWorkflowExecutionStore{} - persistWorkflowExecution(context.Background(), successStore, &types.WorkflowExecution{ExecutionID: "exec-1"}) - require.True(t, successStore.called) - - errorStore := &persistWorkflowExecutionStore{err: errors.New("store failed")} - persistWorkflowExecution(context.Background(), errorStore, &types.WorkflowExecution{ExecutionID: "exec-2"}) - require.True(t, errorStore.called) - }) - - t.Run("execute reasoner invalid workflow and missing reasoner", func(t *testing.T) { - store := newReasonerHandlerStorage(&types.AgentNode{ - ID: "node-1", - BaseURL: "http://agent.invalid", - Reasoners: []types.ReasonerDefinition{{ID: "other"}}, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusReady, - }) - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.other", strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Workflow-ID", strings.Repeat("w", 256)) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - require.Equal(t, http.StatusBadRequest, rec.Code) - - req = httptest.NewRequest(http.MethodPost, "/reasoners/node-1.missing", strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - require.Equal(t, http.StatusNotFound, rec.Code) - }) - t.Run("webhook helper branches", func(t *testing.T) { require.Equal(t, "approved", normalizeDecision("approve")) require.Equal(t, "approved", normalizeDecision("continue")) diff --git a/control-plane/internal/handlers/coverage_handlers_92_target_test.go b/control-plane/internal/handlers/coverage_handlers_92_target_test.go index 1bb461c25..84d1c0a6f 100644 --- a/control-plane/internal/handlers/coverage_handlers_92_target_test.go +++ b/control-plane/internal/handlers/coverage_handlers_92_target_test.go @@ -214,7 +214,7 @@ func TestExecutionCleanupService_StartStopBranches(t *testing.T) { // logger-swapping tests in execution_cleanup_test.go. t.Run("disabled start is no-op", func(t *testing.T) { - service := NewExecutionCleanupService(&cleanupStoreMock{}, config.ExecutionCleanupConfig{Enabled: false}) + service := NewExecutionCleanupService(&cleanupStoreMock{}, nil, config.ExecutionCleanupConfig{Enabled: false}) require.NoError(t, service.Start(context.Background())) require.False(t, service.isRunning) require.NoError(t, service.Stop()) @@ -222,7 +222,7 @@ func TestExecutionCleanupService_StartStopBranches(t *testing.T) { t.Run("stop channel branch exits cleanly", func(t *testing.T) { logBuffer := setupExecutionCleanupTestLogger(t) - service := NewExecutionCleanupService(&cleanupStoreMock{}, config.ExecutionCleanupConfig{ + service := NewExecutionCleanupService(&cleanupStoreMock{}, nil, config.ExecutionCleanupConfig{ Enabled: true, RetentionPeriod: time.Hour, CleanupInterval: time.Hour, @@ -426,7 +426,7 @@ func TestNodeRESTHandlersAdditionalBranches(t *testing.T) { assert.Empty(t, store.heartbeats) }) - t.Run("action ack and claim refresh heartbeats", func(t *testing.T) { + t.Run("action ack refreshes heartbeat", func(t *testing.T) { store := &nodeRESTStorageStub{ agent: &types.AgentNode{ID: "node-3", Version: "v3"}, } @@ -434,7 +434,6 @@ func TestNodeRESTHandlersAdditionalBranches(t *testing.T) { router := gin.New() router.POST("/nodes/:node_id/actions/ack", NodeActionAckHandler(store, presence, 2*time.Minute)) - router.POST("/actions/claim", ClaimActionsHandler(store, presence, 3*time.Minute)) ackReq := httptest.NewRequest(http.MethodPost, "/nodes/node-3/actions/ack", strings.NewReader(`{"action_id":"act-1","status":"RUNNING"}`)) ackReq.Header.Set("Content-Type", "application/json") @@ -442,18 +441,8 @@ func TestNodeRESTHandlersAdditionalBranches(t *testing.T) { router.ServeHTTP(ackResp, ackReq) require.Equal(t, http.StatusOK, ackResp.Code) - claimReq := httptest.NewRequest(http.MethodPost, "/actions/claim", strings.NewReader(`{"node_id":"node-3","wait_seconds":0}`)) - claimReq.Header.Set("Content-Type", "application/json") - claimResp := httptest.NewRecorder() - router.ServeHTTP(claimResp, claimReq) - require.Equal(t, http.StatusOK, claimResp.Code) - - require.Len(t, store.heartbeats, 2) + require.Len(t, store.heartbeats, 1) assert.True(t, presence.HasLease("node-3")) - - var body map[string]any - require.NoError(t, json.Unmarshal(claimResp.Body.Bytes(), &body)) - assert.Equal(t, float64(5), body["next_poll_after"]) }) t.Run("shutdown uses version lookup and clears presence", func(t *testing.T) { @@ -824,23 +813,6 @@ func TestNodeHandlerAdditionalErrorBranches(t *testing.T) { assert.Contains(t, resp.Body.String(), "Too many node IDs requested") }) - t.Run("start and stop return node not found before manager checks", func(t *testing.T) { - store := &nodeRESTStorageStub{getAgentErr: errors.New("missing")} - router := gin.New() - router.POST("/nodes/:node_id/start", StartNodeHandler(nil, store)) - router.POST("/nodes/:node_id/stop", StopNodeHandler(nil, store)) - - req := httptest.NewRequest(http.MethodPost, "/nodes/node-x/start", nil) - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - require.Equal(t, http.StatusNotFound, resp.Code) - - req = httptest.NewRequest(http.MethodPost, "/nodes/node-x/stop", nil) - resp = httptest.NewRecorder() - router.ServeHTTP(resp, req) - require.Equal(t, http.StatusNotFound, resp.Code) - }) - t.Run("register node surfaces storage failure", func(t *testing.T) { store := ®isterNodeStorageStub{ nodeRESTStorageStub: &nodeRESTStorageStub{}, diff --git a/control-plane/internal/handlers/coverage_handlers_gap_test.go b/control-plane/internal/handlers/coverage_handlers_gap_test.go index 44299c1da..69b2b291f 100644 --- a/control-plane/internal/handlers/coverage_handlers_gap_test.go +++ b/control-plane/internal/handlers/coverage_handlers_gap_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "io" "net/http" "net/http/httptest" "strings" @@ -279,76 +278,6 @@ func TestReadCloserClose(t *testing.T) { assert.NoError(t, rc.Close()) } -func TestUpdateLifecycleStatusHandler_ErrorPaths(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []struct { - name string - body string - expectedStatusCode int - expectedBody string - }{ - { - name: "invalid json", - body: `{`, - expectedStatusCode: http.StatusBadRequest, - expectedBody: "Invalid JSON format", - }, - { - name: "invalid lifecycle status", - body: `{"lifecycle_status":"unknown"}`, - expectedStatusCode: http.StatusBadRequest, - expectedBody: "Invalid lifecycle status", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - router := gin.New() - router.POST("/nodes/:node_id/lifecycle", UpdateLifecycleStatusHandler(&nodeRESTStorageStub{agent: &types.AgentNode{ID: "node-1"}}, nil, nil)) - - req := httptest.NewRequest(http.MethodPost, "/nodes/node-1/lifecycle", strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, tt.expectedStatusCode, resp.Code) - assert.Contains(t, resp.Body.String(), tt.expectedBody) - }) - } - - t.Run("missing node returns not found", func(t *testing.T) { - store := &nodeRESTStorageStub{getAgentErr: errors.New("missing")} - router := gin.New() - router.POST("/nodes/:node_id/lifecycle", UpdateLifecycleStatusHandler(store, nil, nil)) - - req := httptest.NewRequest(http.MethodPost, "/nodes/node-1/lifecycle", strings.NewReader(`{"lifecycle_status":"ready"}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusNotFound, resp.Code) - assert.Contains(t, resp.Body.String(), "node not found") - }) - - t.Run("storage update failure returns internal server error", func(t *testing.T) { - store := &lifecycleUpdateErrorStorageStub{ - nodeRESTStorageStub: &nodeRESTStorageStub{agent: &types.AgentNode{ID: "node-1", LifecycleStatus: types.AgentStatusReady}}, - updateErr: errors.New("update failed"), - } - router := gin.New() - router.POST("/nodes/:node_id/lifecycle", UpdateLifecycleStatusHandler(store, nil, nil)) - - req := httptest.NewRequest(http.MethodPost, "/nodes/node-1/lifecycle", strings.NewReader(`{"lifecycle_status":"offline"}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusInternalServerError, resp.Code) - assert.Contains(t, resp.Body.String(), "failed to update lifecycle status") - }) -} - func TestRegisterServerlessAgentHandler_ErrorPaths(t *testing.T) { gin.SetMode(gin.TestMode) @@ -679,242 +608,6 @@ func TestMarshalDataWithLogging(t *testing.T) { assert.Contains(t, err.Error(), "failed to marshal payload") } -func newSkillAgent(baseURL string) *types.AgentNode { - return &types.AgentNode{ - ID: "node-1", - BaseURL: baseURL, - Skills: []types.SkillDefinition{{ID: "summarize"}}, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusReady, - } -} - -func TestExecuteSkillHandler_BasicPaths(t *testing.T) { - gin.SetMode(gin.TestMode) - - t.Run("malformed skill id", func(t *testing.T) { - store := newReasonerHandlerStorage(nil) - router := gin.New() - router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/skills/not-valid", strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusBadRequest, resp.Code) - assert.Contains(t, resp.Body.String(), "node_id.skill_name") - }) - - t.Run("missing skill on node", func(t *testing.T) { - store := newReasonerHandlerStorage(newSkillAgent("http://agent.invalid")) - router := gin.New() - router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/skills/node-1.unknown", strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusNotFound, resp.Code) - assert.Contains(t, resp.Body.String(), "skill 'unknown' not found") - }) - - t.Run("successful execution persists workflow execution", func(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/skills/summarize", r.URL.Path) - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - defer r.Body.Close() - require.JSONEq(t, `{"topic":"status"}`, string(body)) - require.Equal(t, "wf-skill", r.Header.Get("X-Workflow-ID")) - require.Equal(t, "session-1", r.Header.Get("X-Session-ID")) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"summary":"done"}`)) - })) - defer agentServer.Close() - - store := newReasonerHandlerStorage(newSkillAgent(agentServer.URL)) - router := gin.New() - router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/skills/node-1.summarize", strings.NewReader(`{"input":{"topic":"status"}}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Workflow-ID", "wf-skill") - req.Header.Set("X-Session-ID", "session-1") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code) - - var payload ExecuteReasonerResponse - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) - assert.Equal(t, "node-1", payload.NodeID) - - records, err := store.QueryWorkflowExecutions(context.Background(), types.WorkflowExecutionFilters{}) - require.NoError(t, err) - require.Len(t, records, 1) - assert.Equal(t, string(types.ExecutionStatusSucceeded), records[0].Status) - assert.Equal(t, "summarize", records[0].ReasonerID) - assert.JSONEq(t, `{"summary":"done"}`, string(records[0].OutputData)) - }) -} - -func TestExecuteReasonerHandler_AdditionalBranches(t *testing.T) { - gin.SetMode(gin.TestMode) - - t.Run("body validation", func(t *testing.T) { - store := newReasonerHandlerStorage(newReasonerAgent("http://agent.invalid")) - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(`{"input":`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusBadRequest, resp.Code) - }) - - t.Run("missing reasoner on node", func(t *testing.T) { - store := newReasonerHandlerStorage(newReasonerAgent("http://agent.invalid")) - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.other", strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusNotFound, resp.Code) - assert.Contains(t, resp.Body.String(), "reasoner 'other' not found") - }) - - t.Run("invalid agent response persists failure", func(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`not-json`)) - })) - defer agentServer.Close() - - store := newReasonerHandlerStorage(newReasonerAgent(agentServer.URL)) - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(`{"input":{"ok":true}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusInternalServerError, resp.Code) - assert.Contains(t, resp.Body.String(), "failed to parse agent response") - - records, err := store.QueryWorkflowExecutions(context.Background(), types.WorkflowExecutionFilters{}) - require.NoError(t, err) - require.Len(t, records, 1) - assert.Equal(t, string(types.ExecutionStatusFailed), records[0].Status) - require.NotNil(t, records[0].ErrorMessage) - assert.Contains(t, *records[0].ErrorMessage, "failed to parse agent response") - }) -} - -func TestExecuteSkillHandler_AdditionalBranches(t *testing.T) { - gin.SetMode(gin.TestMode) - - t.Run("node availability errors", func(t *testing.T) { - tests := []struct { - name string - agent *types.AgentNode - getAgentErr error - wantCode int - wantBody string - }{ - { - name: "node not found", - getAgentErr: errors.New("missing"), - wantCode: http.StatusNotFound, - wantBody: "node 'node-1' not found", - }, - { - name: "inactive node", - agent: &types.AgentNode{ - ID: "node-1", - BaseURL: "http://agent.invalid", - Skills: []types.SkillDefinition{{ID: "summarize"}}, - HealthStatus: types.HealthStatusInactive, - LifecycleStatus: types.AgentStatusReady, - }, - wantCode: http.StatusServiceUnavailable, - wantBody: "is not healthy", - }, - { - name: "offline node", - agent: &types.AgentNode{ - ID: "node-1", - BaseURL: "http://agent.invalid", - Skills: []types.SkillDefinition{{ID: "summarize"}}, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusOffline, - }, - wantCode: http.StatusServiceUnavailable, - wantBody: "is offline", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - store := newReasonerHandlerStorage(tt.agent) - store.getAgentErr = tt.getAgentErr - - router := gin.New() - router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/skills/node-1.summarize", strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, tt.wantCode, resp.Code) - assert.Contains(t, resp.Body.String(), tt.wantBody) - }) - } - }) - - t.Run("request validation and upstream parse failure", func(t *testing.T) { - store := newReasonerHandlerStorage(newSkillAgent("http://agent.invalid")) - router := gin.New() - router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/skills/node-1.summarize", strings.NewReader(`{"input":`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - require.Equal(t, http.StatusBadRequest, resp.Code) - - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`not-json`)) - })) - defer agentServer.Close() - - store = newReasonerHandlerStorage(newSkillAgent(agentServer.URL)) - router = gin.New() - router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) - - req = httptest.NewRequest(http.MethodPost, "/skills/node-1.summarize", strings.NewReader(`{"input":{"topic":"status"}}`)) - req.Header.Set("Content-Type", "application/json") - resp = httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusInternalServerError, resp.Code) - assert.Contains(t, resp.Body.String(), "failed to parse agent response") - - records, err := store.QueryWorkflowExecutions(context.Background(), types.WorkflowExecutionFilters{}) - require.NoError(t, err) - require.Len(t, records, 1) - assert.Equal(t, string(types.ExecutionStatusFailed), records[0].Status) - }) -} - func TestExecutionHelpersAndStatusHandlers(t *testing.T) { t.Run("extract requested llm endpoint", func(t *testing.T) { tests := []struct { diff --git a/control-plane/internal/handlers/coverage_low_branches_test.go b/control-plane/internal/handlers/coverage_low_branches_test.go index 7ef6af737..2378906a1 100644 --- a/control-plane/internal/handlers/coverage_low_branches_test.go +++ b/control-plane/internal/handlers/coverage_low_branches_test.go @@ -454,7 +454,7 @@ func TestExecutionCleanupService_ForceCleanup(t *testing.T) { {count: 1}, }, } - svc := NewExecutionCleanupService(store, config.ExecutionCleanupConfig{ + svc := NewExecutionCleanupService(store, nil, config.ExecutionCleanupConfig{ BatchSize: 2, RetentionPeriod: 24 * time.Hour, }) @@ -479,7 +479,7 @@ func TestExecutionCleanupService_ForceCleanup(t *testing.T) { {err: errors.New("cleanup failed")}, }, } - svc := NewExecutionCleanupService(store, config.ExecutionCleanupConfig{ + svc := NewExecutionCleanupService(store, nil, config.ExecutionCleanupConfig{ BatchSize: 5, RetentionPeriod: time.Hour, }) @@ -495,7 +495,7 @@ func TestExecutionCleanupService_ForceCleanup(t *testing.T) { {count: 1}, }, } - svc := NewExecutionCleanupService(store, config.ExecutionCleanupConfig{ + svc := NewExecutionCleanupService(store, nil, config.ExecutionCleanupConfig{ BatchSize: 1, RetentionPeriod: time.Hour, }) @@ -850,62 +850,6 @@ func TestNodeStatusHandlers_WithStatusManager(t *testing.T) { assert.Contains(t, rec.Body.String(), `"successful":2`) }) - t.Run("start and stop node succeed", func(t *testing.T) { - router := gin.New() - - startStore := &statusManagerStorageStub{ - nodeRESTStorageStub: &nodeRESTStorageStub{ - agent: &types.AgentNode{ - ID: "node-1", - HealthStatus: types.HealthStatusInactive, - LifecycleStatus: types.AgentStatusOffline, - LastHeartbeat: time.Now().UTC(), - }, - }, - } - stopStore := &statusManagerStorageStub{ - nodeRESTStorageStub: &nodeRESTStorageStub{ - agent: &types.AgentNode{ - ID: "node-2", - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusReady, - LastHeartbeat: time.Now().UTC(), - }, - }, - } - - router.POST("/nodes/:node_id/start", StartNodeHandler(newManager(startStore), startStore)) - router.POST("/nodes/:node_id/stop", StopNodeHandler(newManager(stopStore), stopStore)) - - startReq := httptest.NewRequest(http.MethodPost, "/nodes/node-1/start", nil) - startRec := httptest.NewRecorder() - router.ServeHTTP(startRec, startReq) - require.Equal(t, http.StatusOK, startRec.Code) - assert.Equal(t, types.AgentStatusStarting, *startStore.updatedLifecycle) - - stopReq := httptest.NewRequest(http.MethodPost, "/nodes/node-2/stop", nil) - stopRec := httptest.NewRecorder() - router.ServeHTTP(stopRec, stopReq) - require.Equal(t, http.StatusOK, stopRec.Code) - assert.Equal(t, types.AgentStatusOffline, *stopStore.updatedLifecycle) - }) - - t.Run("start and stop return not found when node lookup fails", func(t *testing.T) { - store := &nodeRESTStorageStub{getAgentErr: errors.New("missing")} - router := gin.New() - router.POST("/nodes/:node_id/start", StartNodeHandler(nil, store)) - router.POST("/nodes/:node_id/stop", StopNodeHandler(nil, store)) - - startReq := httptest.NewRequest(http.MethodPost, "/nodes/missing/start", nil) - startRec := httptest.NewRecorder() - router.ServeHTTP(startRec, startReq) - require.Equal(t, http.StatusNotFound, startRec.Code) - - stopReq := httptest.NewRequest(http.MethodPost, "/nodes/missing/stop", nil) - stopRec := httptest.NewRecorder() - router.ServeHTTP(stopRec, stopReq) - require.Equal(t, http.StatusNotFound, stopRec.Code) - }) } func TestLogMemoryAccess(t *testing.T) { diff --git a/control-plane/internal/handlers/coverage_raise_88_test.go b/control-plane/internal/handlers/coverage_raise_88_test.go index 6e04027fd..3bab40b6e 100644 --- a/control-plane/internal/handlers/coverage_raise_88_test.go +++ b/control-plane/internal/handlers/coverage_raise_88_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "io" "net/http" "net/http/httptest" "strings" @@ -111,191 +110,6 @@ func (s *registerCoverageStore) RegisterAgent(ctx context.Context, agent *types. return s.nodeRESTStorageStub.RegisterAgent(ctx, agent) } -func TestExecuteReasonerAndSkillHandlers_AdditionalCoverage(t *testing.T) { - gin.SetMode(gin.TestMode) - - t.Run("reasoner serverless propagates optional headers and context", func(t *testing.T) { - var receivedHeaders http.Header - var receivedBody map[string]interface{} - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedHeaders = r.Header.Clone() - defer r.Body.Close() - - require.Equal(t, "/execute", r.URL.Path) - require.NoError(t, json.NewDecoder(r.Body).Decode(&receivedBody)) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - store := newReasonerHandlerStorage(&types.AgentNode{ - ID: "node-1", - BaseURL: server.URL, - DeploymentType: "serverless", - Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusReady, - }) - - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(`{"input":{"topic":"status"}}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Workflow-ID", "wf-serverless") - req.Header.Set("X-Session-ID", "session-1") - req.Header.Set("X-Actor-ID", "actor-1") - req.Header.Set("X-Parent-Workflow-ID", "wf-parent") - req.Header.Set("X-Parent-Execution-ID", "exec-parent") - req.Header.Set("X-Root-Workflow-ID", "wf-root") - req.Header.Set("X-Workflow-Name", "Serverless Flow") - req.Header.Set("X-Workflow-Tags", "ops, platform") - req.Header.Set("X-Caller-DID", "did:web:caller.example") - req.Header.Set("X-Target-DID", "did:web:target.example") - req.Header.Set("X-Agent-Node-DID", "did:web:node.example") - - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code) - require.Equal(t, "did:web:caller.example", receivedHeaders.Get("X-Caller-DID")) - require.Equal(t, "did:web:target.example", receivedHeaders.Get("X-Target-DID")) - require.Equal(t, "did:web:node.example", receivedHeaders.Get("X-Agent-Node-DID")) - require.Equal(t, "wf-parent", receivedHeaders.Get("X-Parent-Workflow-ID")) - require.Equal(t, "exec-parent", receivedHeaders.Get("X-Parent-Execution-ID")) - require.Equal(t, "wf-root", receivedHeaders.Get("X-Root-Workflow-ID")) - require.Equal(t, "Serverless Flow", receivedHeaders.Get("X-Workflow-Name")) - require.Equal(t, "ops, platform", receivedHeaders.Get("X-Workflow-Tags")) - require.Equal(t, "session-1", receivedHeaders.Get("X-Session-ID")) - require.Equal(t, "actor-1", receivedHeaders.Get("X-Actor-ID")) - - execCtx, ok := receivedBody["execution_context"].(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "exec-parent", execCtx["parent_execution_id"]) - assert.Equal(t, "session-1", execCtx["session_id"]) - assert.Equal(t, "actor-1", execCtx["actor_id"]) - assert.Equal(t, "reasoner", receivedBody["type"]) - - records, err := store.QueryWorkflowExecutions(context.Background(), types.WorkflowExecutionFilters{}) - require.NoError(t, err) - require.Len(t, records, 1) - assert.Equal(t, "actor-1", *records[0].ActorID) - assert.Equal(t, "wf-parent", *records[0].ParentWorkflowID) - assert.Equal(t, "exec-parent", *records[0].ParentExecutionID) - assert.Equal(t, "wf-root", *records[0].RootWorkflowID) - assert.Equal(t, "Serverless Flow", *records[0].WorkflowName) - assert.Equal(t, []string{"ops", "platform"}, records[0].WorkflowTags) - }) - - t.Run("skill forwards optional headers and persists metadata", func(t *testing.T) { - var receivedHeaders http.Header - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedHeaders = r.Header.Clone() - defer r.Body.Close() - require.Equal(t, "/skills/summarize", r.URL.Path) - _, _ = io.ReadAll(r.Body) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"summary":"ok"}`)) - })) - defer server.Close() - - store := newReasonerHandlerStorage(newSkillAgent(server.URL)) - router := gin.New() - router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/skills/node-1.summarize", strings.NewReader(`{"input":{"topic":"status"}}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Workflow-ID", "wf-skill") - req.Header.Set("X-Session-ID", "session-1") - req.Header.Set("X-Actor-ID", "actor-1") - req.Header.Set("X-Parent-Workflow-ID", "wf-parent") - req.Header.Set("X-Parent-Execution-ID", "exec-parent") - req.Header.Set("X-Root-Workflow-ID", "wf-root") - req.Header.Set("X-Workflow-Name", "Skill Flow") - req.Header.Set("X-Workflow-Tags", "ops, platform") - req.Header.Set("X-Caller-DID", "did:web:caller.example") - req.Header.Set("X-Target-DID", "did:web:target.example") - req.Header.Set("X-Agent-Node-DID", "did:web:node.example") - - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code) - require.Equal(t, "did:web:caller.example", receivedHeaders.Get("X-Caller-DID")) - require.Equal(t, "did:web:target.example", receivedHeaders.Get("X-Target-DID")) - require.Equal(t, "did:web:node.example", receivedHeaders.Get("X-Agent-Node-DID")) - require.Equal(t, "wf-parent", receivedHeaders.Get("X-Parent-Workflow-ID")) - require.Equal(t, "exec-parent", receivedHeaders.Get("X-Parent-Execution-ID")) - require.Equal(t, "wf-root", receivedHeaders.Get("X-Root-Workflow-ID")) - require.Equal(t, "Skill Flow", receivedHeaders.Get("X-Workflow-Name")) - require.Equal(t, "ops, platform", receivedHeaders.Get("X-Workflow-Tags")) - require.Equal(t, "actor-1", receivedHeaders.Get("X-Actor-ID")) - - records, err := store.QueryWorkflowExecutions(context.Background(), types.WorkflowExecutionFilters{}) - require.NoError(t, err) - require.Len(t, records, 1) - assert.Equal(t, "actor-1", *records[0].ActorID) - assert.Equal(t, "wf-parent", *records[0].ParentWorkflowID) - assert.Equal(t, "exec-parent", *records[0].ParentExecutionID) - assert.Equal(t, "wf-root", *records[0].RootWorkflowID) - assert.Equal(t, "Skill Flow", *records[0].WorkflowName) - assert.Equal(t, []string{"ops", "platform"}, records[0].WorkflowTags) - }) -} - -func TestExecuteReasonerAndSkillHandlers_TransportFailures(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []struct { - name string - route string - target string - newStore func() *reasonerHandlerStorage - newHandle func(*reasonerHandlerStorage) gin.HandlerFunc - }{ - { - name: "reasoner", - route: "/reasoners/:reasoner_id", - target: "/reasoners/node-1.ping", - newStore: func() *reasonerHandlerStorage { return newReasonerHandlerStorage(newReasonerAgent("http://127.0.0.1:1")) }, - newHandle: func(s *reasonerHandlerStorage) gin.HandlerFunc { return ExecuteReasonerHandler(s) }, - }, - { - name: "skill", - route: "/skills/:skill_id", - target: "/skills/node-1.summarize", - newStore: func() *reasonerHandlerStorage { return newReasonerHandlerStorage(newSkillAgent("http://127.0.0.1:1")) }, - newHandle: func(s *reasonerHandlerStorage) gin.HandlerFunc { return ExecuteSkillHandler(s) }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - store := tt.newStore() - router := gin.New() - router.POST(tt.route, tt.newHandle(store)) - - req := httptest.NewRequest(http.MethodPost, tt.target, strings.NewReader(`{"input":{"topic":"status"}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusServiceUnavailable, resp.Code) - - records, err := store.QueryWorkflowExecutions(context.Background(), types.WorkflowExecutionFilters{}) - require.NoError(t, err) - require.Len(t, records, 1) - assert.Equal(t, string(types.ExecutionStatusFailed), records[0].Status) - require.NotNil(t, records[0].ErrorMessage) - assert.NotEmpty(t, *records[0].ErrorMessage) - }) - } -} - func TestExecutionController_AdditionalCoverage(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/control-plane/internal/handlers/empty_input_test.go b/control-plane/internal/handlers/empty_input_test.go deleted file mode 100644 index 982d4ab86..000000000 --- a/control-plane/internal/handlers/empty_input_test.go +++ /dev/null @@ -1,292 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/services" - "github.com/Agent-Field/agentfield/control-plane/internal/storage" - "github.com/Agent-Field/agentfield/control-plane/pkg/types" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -type reasonerTestStorage struct { - storage.StorageProvider - agent *types.AgentNode - executions []*types.WorkflowExecution -} - -func (s *reasonerTestStorage) GetAgent(ctx context.Context, id string) (*types.AgentNode, error) { - if s.agent != nil && s.agent.ID == id { - return s.agent, nil - } - return nil, nil -} - -func (s *reasonerTestStorage) StoreWorkflowExecution(ctx context.Context, execution *types.WorkflowExecution) error { - if execution != nil { - copy := *execution - s.executions = append(s.executions, ©) - } - return nil -} - -// TestExecuteHandler_EmptyInput verifies that calling the execute endpoint with -// an empty input object ({"input":{}}) succeeds instead of returning 400. -// Reproduction for https://github.com/Agent-Field/agentfield/issues/196. -func TestExecuteHandler_EmptyInput(t *testing.T) { - gin.SetMode(gin.TestMode) - - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - defer r.Body.Close() - require.JSONEq(t, `{}`, string(body)) - - // Agent receives the (empty) input and returns success - var payload map[string]interface{} - require.NoError(t, json.Unmarshal(body, &payload)) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"ok"}`)) - })) - defer agentServer.Close() - - agent := &types.AgentNode{ - ID: "node-1", - BaseURL: agentServer.URL, - Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, - } - - store := newTestExecutionStorage(agent) - payloads := services.NewFilePayloadStore(t.TempDir()) - - router := gin.New() - router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second, "")) - - // Empty input object β€” should be accepted for parameterless skills - req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.ping", - strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code, "empty input {} should be accepted, got: %s", resp.Body.String()) - - var envelope ExecuteResponse - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &envelope)) - require.Equal(t, types.ExecutionStatusSucceeded, envelope.Status) -} - -// TestExecuteHandler_NilInput verifies that calling the execute endpoint with -// no input field at all ({}) succeeds β€” the handler should default to empty map. -func TestExecuteHandler_NilInput(t *testing.T) { - gin.SetMode(gin.TestMode) - - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - defer r.Body.Close() - require.JSONEq(t, `{}`, string(body)) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"status":"ok"}`)) - })) - defer agentServer.Close() - - agent := &types.AgentNode{ - ID: "node-1", - BaseURL: agentServer.URL, - Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, - } - - store := newTestExecutionStorage(agent) - payloads := services.NewFilePayloadStore(t.TempDir()) - - router := gin.New() - router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second, "")) - - // No input field at all β€” should default to empty map - req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.ping", - strings.NewReader(`{}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code, "missing input field should be accepted, got: %s", resp.Body.String()) -} - -// TestExecuteRequest_BindingAcceptsEmptyInput directly tests that the ExecuteRequest -// struct's binding tags accept empty and nil input (unit-level binding test). -func TestExecuteRequest_BindingAcceptsEmptyInput(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []struct { - name string - body string - }{ - {"empty input object", `{"input":{}}`}, - {"no input field", `{}`}, - {"input with context", `{"input":{},"context":{"session":"abc"}}`}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("POST", "/", strings.NewReader(tt.body)) - c.Request.Header.Set("Content-Type", "application/json") - - var req ExecuteRequest - err := c.ShouldBindJSON(&req) - require.NoError(t, err, "binding should accept %s", tt.name) - }) - } -} - -// TestExecuteReasonerRequest_BindingAcceptsEmptyInput directly tests that the -// ExecuteReasonerRequest struct's binding tags accept empty and nil input. -func TestExecuteReasonerRequest_BindingAcceptsEmptyInput(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []struct { - name string - body string - }{ - {"empty input object", `{"input":{}}`}, - {"no input field", `{}`}, - {"input with context", `{"input":{},"context":{"session":"abc"}}`}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("POST", "/", strings.NewReader(tt.body)) - c.Request.Header.Set("Content-Type", "application/json") - - var req ExecuteReasonerRequest - err := c.ShouldBindJSON(&req) - require.NoError(t, err, "binding should accept %s", tt.name) - }) - } -} - -// TestExecuteRequest_BindingStillRequiresJSON verifies that completely invalid -// input is still rejected (the fix doesn't break validation entirely). -func TestExecuteRequest_BindingStillRequiresJSON(t *testing.T) { - gin.SetMode(gin.TestMode) - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("POST", "/", strings.NewReader("not-json")) - c.Request.Header.Set("Content-Type", "application/json") - - var req ExecuteRequest - err := c.ShouldBindJSON(&req) - require.Error(t, err, "invalid JSON should still be rejected") -} - -func TestExecuteReasonerHandler_EmptyAndNilInput(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []struct { - name string - body string - }{ - {name: "empty input object", body: `{"input":{}}`}, - {name: "nil input object", body: `{}`}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/reasoners/ping", r.URL.Path) - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - defer r.Body.Close() - require.JSONEq(t, `{}`, string(body)) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer agentServer.Close() - - store := &reasonerTestStorage{agent: &types.AgentNode{ - ID: "node-1", - BaseURL: agentServer.URL, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusReady, - Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, - }} - - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) - require.NotEmpty(t, store.executions) - require.JSONEq(t, `{}`, string(store.executions[0].InputData)) - }) - } -} - -func TestExecuteSkillHandler_EmptyAndNilInput(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []struct { - name string - body string - }{ - {name: "empty input object", body: `{"input":{}}`}, - {name: "nil input object", body: `{}`}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/skills/list_items", r.URL.Path) - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - defer r.Body.Close() - require.JSONEq(t, `{}`, string(body)) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer agentServer.Close() - - store := &reasonerTestStorage{agent: &types.AgentNode{ - ID: "node-1", - BaseURL: agentServer.URL, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusReady, - Skills: []types.SkillDefinition{{ID: "list_items"}}, - }} - - router := gin.New() - router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/skills/node-1.list_items", strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) - require.NotEmpty(t, store.executions) - require.JSONEq(t, `{}`, string(store.executions[0].InputData)) - }) - } -} diff --git a/control-plane/internal/handlers/execute_test.go b/control-plane/internal/handlers/execute_test.go index 084ce383e..d3b61ecb8 100644 --- a/control-plane/internal/handlers/execute_test.go +++ b/control-plane/internal/handlers/execute_test.go @@ -41,9 +41,13 @@ func (m *MockStorageProvider) GetAgent(ctx context.Context, id string) (*types.A return args.Get(0).(*types.AgentNode), args.Error(1) } -func (m *MockStorageProvider) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, error) { +func (m *MockStorageProvider) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, []string, error) { args := m.Called(ctx, retentionPeriod, batchSize) - return args.Int(0), args.Error(1) + var uris []string + if v := args.Get(1); v != nil { + uris = v.([]string) + } + return args.Int(0), uris, args.Error(2) } func (m *MockStorageProvider) MarkStaleExecutions(ctx context.Context, staleAfter time.Duration, limit int) (int, error) { @@ -423,9 +427,9 @@ func TestCleanupOldExecutions(t *testing.T) { mockStorage := &MockStorageProvider{} // Test successful cleanup - mockStorage.On("CleanupOldExecutions", mock.Anything, 24*time.Hour, 100).Return(5, nil) + mockStorage.On("CleanupOldExecutions", mock.Anything, 24*time.Hour, 100).Return(5, nil, nil) - count, err := mockStorage.CleanupOldExecutions(context.Background(), 24*time.Hour, 100) + count, _, err := mockStorage.CleanupOldExecutions(context.Background(), 24*time.Hour, 100) assert.NoError(t, err) assert.Equal(t, 5, count) diff --git a/control-plane/internal/handlers/execution_cleanup.go b/control-plane/internal/handlers/execution_cleanup.go index f49097e56..b8f956567 100644 --- a/control-plane/internal/handlers/execution_cleanup.go +++ b/control-plane/internal/handlers/execution_cleanup.go @@ -7,17 +7,19 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/config" "github.com/Agent-Field/agentfield/control-plane/internal/logger" + "github.com/Agent-Field/agentfield/control-plane/internal/services" "github.com/Agent-Field/agentfield/control-plane/internal/storage" ) // ExecutionCleanupService manages the background cleanup of old executions type ExecutionCleanupService struct { - storage storage.StorageProvider - config config.ExecutionCleanupConfig - stopChan chan struct{} - wg sync.WaitGroup - isRunning bool - mu sync.RWMutex + storage storage.StorageProvider + payloadStore services.PayloadStore + config config.ExecutionCleanupConfig + stopChan chan struct{} + wg sync.WaitGroup + isRunning bool + mu sync.RWMutex // Metrics totalCleaned int64 @@ -26,11 +28,12 @@ type ExecutionCleanupService struct { } // NewExecutionCleanupService creates a new execution cleanup service -func NewExecutionCleanupService(storage storage.StorageProvider, config config.ExecutionCleanupConfig) *ExecutionCleanupService { +func NewExecutionCleanupService(storage storage.StorageProvider, payloadStore services.PayloadStore, config config.ExecutionCleanupConfig) *ExecutionCleanupService { return &ExecutionCleanupService{ - storage: storage, - config: config, - stopChan: make(chan struct{}), + storage: storage, + payloadStore: payloadStore, + config: config, + stopChan: make(chan struct{}), } } @@ -171,7 +174,7 @@ func (ecs *ExecutionCleanupService) performCleanup(ctx context.Context) { } for { - cleaned, err := ecs.storage.CleanupOldExecutions(cleanupCtx, ecs.config.RetentionPeriod, ecs.config.BatchSize) + cleaned, payloadURIs, err := ecs.storage.CleanupOldExecutions(cleanupCtx, ecs.config.RetentionPeriod, ecs.config.BatchSize) if err != nil { ecs.mu.Lock() ecs.lastCleanupErr = err @@ -185,6 +188,7 @@ func (ecs *ExecutionCleanupService) performCleanup(ctx context.Context) { return } + ecs.removePayloadFiles(cleanupCtx, payloadURIs) totalCleaned += cleaned // If we cleaned fewer than the batch size, we're done @@ -235,11 +239,12 @@ func (ecs *ExecutionCleanupService) ForceCleanup(ctx context.Context) (int, erro totalCleaned := 0 for { - cleaned, err := ecs.storage.CleanupOldExecutions(cleanupCtx, ecs.config.RetentionPeriod, ecs.config.BatchSize) + cleaned, payloadURIs, err := ecs.storage.CleanupOldExecutions(cleanupCtx, ecs.config.RetentionPeriod, ecs.config.BatchSize) if err != nil { return totalCleaned, err } + ecs.removePayloadFiles(cleanupCtx, payloadURIs) totalCleaned += cleaned // If we cleaned fewer than the batch size, we're done @@ -266,3 +271,18 @@ func (ecs *ExecutionCleanupService) ForceCleanup(ctx context.Context) (int, erro return totalCleaned, nil } + +func (ecs *ExecutionCleanupService) removePayloadFiles(ctx context.Context, uris []string) { + if ecs.payloadStore == nil || len(uris) == 0 { + return + } + + for _, uri := range uris { + if err := ecs.payloadStore.Remove(ctx, uri); err != nil { + logger.Logger.Warn(). + Err(err). + Str("uri", uri). + Msg("failed to remove payload file during execution cleanup") + } + } +} diff --git a/control-plane/internal/handlers/execution_cleanup_test.go b/control-plane/internal/handlers/execution_cleanup_test.go index 23bb2ceff..103c0e0a1 100644 --- a/control-plane/internal/handlers/execution_cleanup_test.go +++ b/control-plane/internal/handlers/execution_cleanup_test.go @@ -16,8 +16,9 @@ import ( ) type cleanupResponse struct { - count int - err error + count int + payloadURIs []string + err error } type cleanupCall struct { @@ -51,7 +52,7 @@ type cleanupStoreMock struct { retryStaleErrs []error } -func (m *cleanupStoreMock) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, error) { +func (m *cleanupStoreMock) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, []string, error) { m.mu.Lock() defer m.mu.Unlock() @@ -63,10 +64,11 @@ func (m *cleanupStoreMock) CleanupOldExecutions(ctx context.Context, retentionPe }) if callIndex < len(m.cleanupResponses) { - return m.cleanupResponses[callIndex].count, m.cleanupResponses[callIndex].err + resp := m.cleanupResponses[callIndex] + return resp.count, resp.payloadURIs, resp.err } - return 0, nil + return 0, nil, nil } func (m *cleanupStoreMock) MarkStaleExecutions(ctx context.Context, staleAfter time.Duration, limit int) (int, error) { @@ -215,7 +217,7 @@ func TestExecutionCleanupService_PerformCleanup_CleansStaleExecutionsInBatches(t } cfg := testExecutionCleanupConfig(3) - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) markCalls := store.getMarkStaleCalls() @@ -271,7 +273,7 @@ func TestExecutionCleanupService_PerformCleanup_SkipsStaleMarkWhenTimeoutIsDisab cfg := testExecutionCleanupConfig(10) cfg.StaleExecutionTimeout = 0 - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) if len(store.getMarkStaleCalls()) != 0 { @@ -305,7 +307,7 @@ func TestExecutionCleanupService_PerformCleanup_NoStaleExecutions(t *testing.T) } cfg := testExecutionCleanupConfig(8) - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) if len(store.getMarkStaleCalls()) != 1 { @@ -343,7 +345,7 @@ func TestExecutionCleanupService_PerformCleanup_AllExecutionsStale(t *testing.T) } cfg := testExecutionCleanupConfig(6) - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) if len(store.getMarkStaleCalls()) != 1 { @@ -386,7 +388,7 @@ func TestExecutionCleanupService_PerformCleanup_AccumulatesMetricsAcrossRuns(t * cfg := testExecutionCleanupConfig(2) cfg.StaleExecutionTimeout = 0 - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) service.performCleanup(context.Background()) @@ -415,7 +417,7 @@ func TestExecutionCleanupService_PerformCleanup_StoresErrorWhenCleanupFails(t *t cfg := testExecutionCleanupConfig(2) cfg.StaleExecutionTimeout = 0 - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) if len(store.getCleanupCalls()) != 2 { @@ -447,7 +449,7 @@ func TestExecutionCleanupService_PerformCleanup_ContinuesWhenMarkStaleFails(t *t } cfg := testExecutionCleanupConfig(5) - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) if len(store.getMarkStaleCalls()) != 1 { @@ -482,7 +484,7 @@ func TestExecutionCleanupService_PerformCleanup_StopsWhenContextIsCancelled(t *t cfg := testExecutionCleanupConfig(2) cfg.StaleExecutionTimeout = 0 - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -517,7 +519,7 @@ func TestExecutionCleanupService_PerformCleanup_MarksStaleWorkflowExecutions(t * } cfg := testExecutionCleanupConfig(10) - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) markCalls := store.getMarkStaleCalls() @@ -554,7 +556,7 @@ func TestExecutionCleanupService_PerformCleanup_ContinuesWhenMarkStaleWorkflowFa } cfg := testExecutionCleanupConfig(5) - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) // Cleanup should still proceed despite workflow stale-marking failure @@ -580,7 +582,7 @@ func TestExecutionCleanupService_PerformCleanup_RetriesStaleWorkflowExecutionsBe cfg := testExecutionCleanupConfig(5) cfg.MaxRetries = 2 - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) service.performCleanup(context.Background()) if len(store.getRetryStaleCalls()) != 1 { @@ -602,7 +604,7 @@ func TestExecutionCleanupService_CleanupLoop_StopsOnContextCancellation(t *testi store := &cleanupStoreMock{} cfg := testExecutionCleanupConfig(5) - service := NewExecutionCleanupService(store, cfg) + service := NewExecutionCleanupService(store, nil, cfg) ctx, cancel := context.WithCancel(context.Background()) if err := service.Start(ctx); err != nil { t.Fatalf("expected start to succeed, got error: %v", err) diff --git a/control-plane/internal/handlers/nodes_rest.go b/control-plane/internal/handlers/nodes_rest.go index 1774bb9bd..a42bb4301 100644 --- a/control-plane/internal/handlers/nodes_rest.go +++ b/control-plane/internal/handlers/nodes_rest.go @@ -181,64 +181,6 @@ func NodeActionAckHandler(storageProvider storage.StorageProvider, presenceManag } } -// ClaimActionsHandler returns pending actions for poll-mode agents. -// Currently the scheduler backend is under construction, so this returns an empty queue but still renews leases. -func ClaimActionsHandler(storageProvider storage.StorageProvider, presenceManager *services.PresenceManager, leaseTTL time.Duration) gin.HandlerFunc { - if leaseTTL <= 0 { - leaseTTL = DefaultLeaseTTL - } - - return func(c *gin.Context) { - ctx := c.Request.Context() - - var payload struct { - NodeID string `json:"node_id"` - MaxItems int `json:"max_items"` - WaitSeconds int `json:"wait_seconds"` - } - - if err := c.ShouldBindJSON(&payload); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload", "details": err.Error()}) - return - } - - if payload.NodeID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "node_id is required"}) - return - } - - if payload.MaxItems <= 0 { - payload.MaxItems = 1 - } - - agent, err := storageProvider.GetAgent(ctx, payload.NodeID) - if err != nil || agent == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "node not found"}) - return - } - - now := time.Now().UTC() - if err := storageProvider.UpdateAgentHeartbeat(ctx, payload.NodeID, agent.Version, now); err != nil { - logger.Logger.Warn().Err(err).Str("node_id", payload.NodeID).Msg("failed to persist heartbeat during claim") - } - if presenceManager != nil { - presenceManager.Touch(payload.NodeID, agent.Version, now) - } - - nextPoll := payload.WaitSeconds - if nextPoll <= 0 { - nextPoll = 5 - } - - c.JSON(http.StatusOK, gin.H{ - "items": []interface{}{}, - "lease_seconds": int(leaseTTL.Seconds()), - "next_poll_after": nextPoll, - "next_lease_renewal": now.Add(leaseTTL).Format(time.RFC3339), - }) - } -} - // NodeShutdownHandler processes graceful shutdown notifications from agents. func NodeShutdownHandler(storageProvider storage.StorageProvider, statusManager *services.StatusManager, presenceManager *services.PresenceManager) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/control-plane/internal/handlers/nodes_rest_test.go b/control-plane/internal/handlers/nodes_rest_test.go index e2ea2a8f5..752abd754 100644 --- a/control-plane/internal/handlers/nodes_rest_test.go +++ b/control-plane/internal/handlers/nodes_rest_test.go @@ -202,35 +202,6 @@ func TestNodeActionAckHandler_ValidationErrors(t *testing.T) { }) } -func TestClaimActionsHandler_ValidationErrors(t *testing.T) { - gin.SetMode(gin.TestMode) - - t.Run("invalid JSON returns 400", func(t *testing.T) { - router := gin.New() - router.POST("/actions/claim", ClaimActionsHandler(nil, nil, 0)) - - req, _ := http.NewRequest("POST", "/actions/claim", bytes.NewBufferString("bad")) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) - - t.Run("missing node_id returns 400", func(t *testing.T) { - router := gin.New() - router.POST("/actions/claim", ClaimActionsHandler(nil, nil, 0)) - - body, _ := json.Marshal(map[string]string{"node_id": ""}) - req, _ := http.NewRequest("POST", "/actions/claim", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) -} - // TestNodeShutdownHandler β€” requires non-nil StorageProvider (see execute_test.go // integration pattern). Shutdown handler uses best-effort JSON parse then // immediately calls storage.GetAgent(), so nil-storage tests always panic. @@ -261,7 +232,7 @@ func TestNormalizePhase_AllPhases_ProduceDistinctStates(t *testing.T) { } } -// NOTE: Full behavioral tests for NodeStatusLeaseHandler, ClaimActionsHandler, +// NOTE: Full behavioral tests for NodeStatusLeaseHandler, // and NodeShutdownHandler require a mock StorageProvider implementation (the // interface has 50+ methods). These handlers call storage.GetAgent() before any // business logic validation, so nil-storage tests can only cover input parsing. diff --git a/control-plane/internal/handlers/nodes_split_coverage_test.go b/control-plane/internal/handlers/nodes_split_coverage_test.go index b446a72be..4e3f2b1f7 100644 --- a/control-plane/internal/handlers/nodes_split_coverage_test.go +++ b/control-plane/internal/handlers/nodes_split_coverage_test.go @@ -164,51 +164,6 @@ func TestHeartbeatHandler_PendingApprovalIgnoresStatusManagerPath(t *testing.T) require.Equal(t, http.StatusOK, rec.Code) } -func TestUpdateLifecycleStatusHandler_Branches(t *testing.T) { - t.Parallel() - gin.SetMode(gin.TestMode) - - t.Run("missing node id returns bad request", func(t *testing.T) { - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPut, "/nodes//lifecycle", - strings.NewReader(`{"lifecycle_status":"ready"}`)) - c.Request.Header.Set("Content-Type", "application/json") - - UpdateLifecycleStatusHandler(&nodeRESTStorageStub{}, nil, nil)(c) - - require.Equal(t, http.StatusBadRequest, rec.Code) - assert.Contains(t, rec.Body.String(), "node_id is required") - }) - - t.Run("status manager success path updates unified status", func(t *testing.T) { - store := &statusManagerStorageStub{ - nodeRESTStorageStub: &nodeRESTStorageStub{ - agent: &types.AgentNode{ - ID: "node-ok", - Version: "v1", - LifecycleStatus: types.AgentStatusStarting, - LastHeartbeat: time.Now().UTC(), - }, - }, - } - manager := services.NewStatusManager(store, services.StatusManagerConfig{}, nil, nil) - - router := gin.New() - router.PUT("/nodes/:node_id/lifecycle", - UpdateLifecycleStatusHandler(store, nil, manager)) - - req := httptest.NewRequest(http.MethodPut, "/nodes/node-ok/lifecycle", - strings.NewReader(`{"lifecycle_status":"ready"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), `"lifecycle_status":"ready"`) - }) -} - func TestNodeStatusHandlers_MissingNodeID(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -234,26 +189,6 @@ func TestNodeStatusHandlers_MissingNodeID(t *testing.T) { require.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "MISSING_NODE_ID") }) - - t.Run("start node", func(t *testing.T) { - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/nodes//start", nil) - - StartNodeHandler(manager, &nodeRESTStorageStub{})(c) - require.Equal(t, http.StatusBadRequest, rec.Code) - assert.Contains(t, rec.Body.String(), "MISSING_NODE_ID") - }) - - t.Run("stop node", func(t *testing.T) { - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/nodes//stop", nil) - - StopNodeHandler(manager, &nodeRESTStorageStub{})(c) - require.Equal(t, http.StatusBadRequest, rec.Code) - assert.Contains(t, rec.Body.String(), "MISSING_NODE_ID") - }) } func TestBulkNodeStatusHandler_AllFailedReturns500(t *testing.T) { diff --git a/control-plane/internal/handlers/nodes_status.go b/control-plane/internal/handlers/nodes_status.go index 7a8761932..774f02bf4 100644 --- a/control-plane/internal/handlers/nodes_status.go +++ b/control-plane/internal/handlers/nodes_status.go @@ -3,7 +3,6 @@ package handlers import ( "fmt" "net/http" - "time" "github.com/Agent-Field/agentfield/control-plane/internal/logger" "github.com/Agent-Field/agentfield/control-plane/internal/services" @@ -13,87 +12,6 @@ import ( "github.com/gin-gonic/gin" ) -// UpdateLifecycleStatusHandler handles lifecycle status updates from agent nodes -// Now integrates with the unified status management system -func UpdateLifecycleStatusHandler(storageProvider storage.StorageProvider, uiService *services.UIService, statusManager *services.StatusManager) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := c.Request.Context() - nodeID := c.Param("node_id") - if nodeID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "node_id is required"}) - return - } - - var statusUpdate struct { - LifecycleStatus string `json:"lifecycle_status" binding:"required"` - } - - if err := c.ShouldBindJSON(&statusUpdate); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON format: " + err.Error()}) - return - } - - // Validate lifecycle status - validStatuses := map[string]bool{ - string(types.AgentStatusStarting): true, - string(types.AgentStatusReady): true, - string(types.AgentStatusDegraded): true, - string(types.AgentStatusOffline): true, - } - - if !validStatuses[statusUpdate.LifecycleStatus] { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid lifecycle status"}) - return - } - - // Verify node exists - existingNode, err := storageProvider.GetAgent(ctx, nodeID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "node not found"}) - return - } - - // Protect pending_approval: only admin tag approval can transition out of this state - newLifecycleStatus := types.AgentLifecycleStatus(statusUpdate.LifecycleStatus) - if existingNode.LifecycleStatus == types.AgentStatusPendingApproval { - logger.Logger.Debug().Msgf("⏸️ Rejecting lifecycle status update for node %s: agent is pending_approval (admin action required)", nodeID) - c.JSON(http.StatusConflict, gin.H{ - "error": "agent_pending_approval", - "message": "Cannot update lifecycle status: agent is awaiting tag approval. Use admin approval endpoint instead.", - }) - return - } - - // Update through unified status system if available - if statusManager != nil { - if err := statusManager.UpdateFromHeartbeat(ctx, nodeID, &newLifecycleStatus, ""); err != nil { - logger.Logger.Error().Err(err).Msgf("❌ Failed to update unified status for node %s", nodeID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"}) - return - } - } else { - // Fallback to legacy update for backward compatibility - if err := storageProvider.UpdateAgentLifecycleStatus(ctx, nodeID, newLifecycleStatus); err != nil { - logger.Logger.Error().Err(err).Msgf("❌ Failed to update lifecycle status for node %s", nodeID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update lifecycle status"}) - return - } - } - - logger.Logger.Debug().Msgf("πŸ”„ Lifecycle status updated for node %s: %s", nodeID, statusUpdate.LifecycleStatus) - - // Note: Status change events are now handled by the unified status system - // The StatusManager will detect status changes and emit appropriate events - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Lifecycle status updated successfully", - "lifecycle_status": statusUpdate.LifecycleStatus, - "timestamp": time.Now().UTC().Format(time.RFC3339), - }) - } -} - // GetNodeStatusHandler handles getting the unified status for a specific node. // Uses the snapshot (no live probe) so that status is controlled by the // background HealthMonitor which has proper consecutive-failure debouncing. @@ -338,123 +256,3 @@ func RefreshAllNodeStatusHandler(statusManager *services.StatusManager, storageP c.JSON(statusCode, response) } } - -// StartNodeHandler handles starting a node (lifecycle management) -func StartNodeHandler(statusManager *services.StatusManager, storageProvider storage.StorageProvider) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := c.Request.Context() - nodeID := c.Param("node_id") - if nodeID == "" { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "node_id is required", - "code": "MISSING_NODE_ID", - }) - return - } - - // Verify node exists - _, err := storageProvider.GetAgent(ctx, nodeID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "error": "Node not found", - "code": "NODE_NOT_FOUND", - }) - return - } - - if statusManager == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Status manager not available", - "code": "SERVICE_UNAVAILABLE", - }) - return - } - - // Update status to starting - startingState := types.AgentStateStarting - update := &types.AgentStatusUpdate{ - State: &startingState, - Source: types.StatusSourceManual, - Reason: "manual start request", - } - - if err := statusManager.UpdateAgentStatus(ctx, nodeID, update); err != nil { - logger.Logger.Error().Err(err).Str("node_id", nodeID).Msg("❌ Failed to start node") - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to start node", - "code": "START_FAILED", - "details": err.Error(), - }) - return - } - - logger.Logger.Debug().Str("node_id", nodeID).Msg("πŸš€ Node start initiated") - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Node start initiated", - "node_id": nodeID, - "status": "starting", - }) - } -} - -// StopNodeHandler handles stopping a node (lifecycle management) -func StopNodeHandler(statusManager *services.StatusManager, storageProvider storage.StorageProvider) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := c.Request.Context() - nodeID := c.Param("node_id") - if nodeID == "" { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "node_id is required", - "code": "MISSING_NODE_ID", - }) - return - } - - // Verify node exists - _, err := storageProvider.GetAgent(ctx, nodeID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "error": "Node not found", - "code": "NODE_NOT_FOUND", - }) - return - } - - if statusManager == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Status manager not available", - "code": "SERVICE_UNAVAILABLE", - }) - return - } - - // Update status to stopping - stoppingState := types.AgentStateStopping - update := &types.AgentStatusUpdate{ - State: &stoppingState, - Source: types.StatusSourceManual, - Reason: "manual stop request", - } - - if err := statusManager.UpdateAgentStatus(ctx, nodeID, update); err != nil { - logger.Logger.Error().Err(err).Str("node_id", nodeID).Msg("❌ Failed to stop node") - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to stop node", - "code": "STOP_FAILED", - "details": err.Error(), - }) - return - } - - logger.Logger.Debug().Str("node_id", nodeID).Msg("πŸ›‘ Node stop initiated") - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Node stop initiated", - "node_id": nodeID, - "status": "stopping", - }) - } -} diff --git a/control-plane/internal/handlers/reasoners.go b/control-plane/internal/handlers/reasoners.go deleted file mode 100644 index 4b76e297b..000000000 --- a/control-plane/internal/handlers/reasoners.go +++ /dev/null @@ -1,726 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" // Added for time.Now() - - "github.com/Agent-Field/agentfield/control-plane/internal/logger" - "github.com/Agent-Field/agentfield/control-plane/internal/storage" - "github.com/Agent-Field/agentfield/control-plane/internal/utils" // Added for ID generation - "github.com/Agent-Field/agentfield/control-plane/pkg/types" // Added for new types - - "github.com/gin-gonic/gin" -) - -// ExecuteReasonerRequest represents a request to execute a reasoner -type ExecuteReasonerRequest struct { - Input map[string]interface{} `json:"input"` - Context map[string]interface{} `json:"context,omitempty"` -} - -func persistWorkflowExecution(ctx context.Context, storageProvider storage.StorageProvider, execution *types.WorkflowExecution) { - if err := storageProvider.StoreWorkflowExecution(ctx, execution); err != nil { - logger.Logger.Error(). - Err(err). - Str("execution_id", execution.ExecutionID). - Msg("failed to persist workflow execution state") - } -} - -// ExecuteReasonerResponse represents the response from executing a reasoner -type ExecuteReasonerResponse struct { - Result interface{} `json:"result"` - NodeID string `json:"node_id"` - Duration int64 `json:"duration_ms"` - Timestamp string `json:"timestamp"` -} - -// ExecuteReasonerHandler handles execution of reasoners with full tracking -func ExecuteReasonerHandler(storageProvider storage.StorageProvider) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := c.Request.Context() - startTime := time.Now() - - // Generate AgentField Request ID - agentfieldRequestID := utils.GenerateAgentFieldRequestID() - - // Extract headers - workflowID := c.GetHeader("X-Workflow-ID") - sessionID := c.GetHeader("X-Session-ID") - actorID := c.GetHeader("X-Actor-ID") - parentWorkflowID := c.GetHeader("X-Parent-Workflow-ID") - parentExecutionID := c.GetHeader("X-Parent-Execution-ID") - rootWorkflowID := c.GetHeader("X-Root-Workflow-ID") - workflowName := c.GetHeader("X-Workflow-Name") - workflowTagsHeader := c.GetHeader("X-Workflow-Tags") - callerDID := c.GetHeader("X-Caller-DID") - targetDID := c.GetHeader("X-Target-DID") - agentNodeDID := c.GetHeader("X-Agent-Node-DID") - - // Generate Workflow ID if not provided - if workflowID == "" { - workflowID = utils.GenerateWorkflowID() - } - - // Validate Workflow ID - if !utils.ValidateWorkflowID(workflowID) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workflow_id format"}) - return - } - - // Generate Execution ID - executionID := utils.GenerateExecutionID() - - // Parse reasoner ID from URL - reasonerID := c.Param("reasoner_id") - if reasonerID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "reasoner_id is required"}) - return - } - - // Split node_id and reasoner_name - parts := strings.Split(reasonerID, ".") - if len(parts) != 2 { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "reasoner_id must be in format 'node_id.reasoner_name'", - }) - return - } - - nodeID := parts[0] - reasonerName := parts[1] - - // Parse request body - var req ExecuteReasonerRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - // Allow empty input for reasoners that take no parameters. - if req.Input == nil { - req.Input = map[string]interface{}{} - } - - // Find the agent node - targetNode, err := storageProvider.GetAgent(ctx, nodeID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "error": fmt.Sprintf("node '%s' not found", nodeID), - }) - return - } - - // Block agents that are known to be unreachable. - // "unknown" (no heartbeat yet) and "active" are allowed through; - // only "inactive" is definitively unreachable. - if targetNode.HealthStatus == types.HealthStatusInactive { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": fmt.Sprintf("agent node '%s' is not healthy (status: %s)", nodeID, targetNode.HealthStatus), - }) - return - } - if targetNode.LifecycleStatus == types.AgentStatusOffline { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": fmt.Sprintf("agent node '%s' is offline", nodeID), - }) - return - } - if targetNode.LifecycleStatus == types.AgentStatusPendingApproval { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "agent_pending_approval", - "message": fmt.Sprintf("agent node '%s' is awaiting tag approval and cannot execute", nodeID), - }) - return - } - - // Check if reasoner exists on the node - reasonerExists := false - for _, reasoner := range targetNode.Reasoners { - if reasoner.ID == reasonerName { - reasonerExists = true - break - } - } - - if !reasonerExists { - c.JSON(http.StatusNotFound, gin.H{ - "error": fmt.Sprintf("reasoner '%s' not found on node '%s'", reasonerName, nodeID), - }) - return - } - - // Create workflow execution record - workflowExecution := &types.WorkflowExecution{ - WorkflowID: workflowID, - ExecutionID: executionID, - AgentFieldRequestID: agentfieldRequestID, - AgentNodeID: nodeID, - ReasonerID: reasonerName, - Status: "running", - StartedAt: startTime, - CreatedAt: startTime, - UpdatedAt: startTime, - } - - // Set optional fields - if sessionID != "" { - workflowExecution.SessionID = &sessionID - } - if actorID != "" { - workflowExecution.ActorID = &actorID - } - if parentWorkflowID != "" { - workflowExecution.ParentWorkflowID = &parentWorkflowID - } - if parentExecutionID != "" { - workflowExecution.ParentExecutionID = &parentExecutionID - } - if rootWorkflowID != "" { - workflowExecution.RootWorkflowID = &rootWorkflowID - } - if workflowName != "" { - workflowExecution.WorkflowName = &workflowName - } - - // Parse workflow tags - if workflowTagsHeader != "" { - tags := strings.Split(workflowTagsHeader, ",") - for i, tag := range tags { - tags[i] = strings.TrimSpace(tag) - } - workflowExecution.WorkflowTags = tags - } - - // Store input data - inputJSON, err := json.Marshal(req.Input) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal input"}) - return - } - workflowExecution.InputData = inputJSON - workflowExecution.InputSize = len(inputJSON) - - // Prepare request to agent node with workflow context propagation - agentURL := fmt.Sprintf("%s/reasoners/%s", targetNode.BaseURL, reasonerName) - agentBody := inputJSON - - if targetNode.DeploymentType == "serverless" { - target := &parsedTarget{ - NodeID: nodeID, - TargetName: reasonerName, - TargetType: "reasoner", - } - var parentPtr, sessionPtr, actorPtr *string - if parentExecutionID != "" { - parentPtr = &parentExecutionID - } - if sessionID != "" { - sessionPtr = &sessionID - } - if actorID != "" { - actorPtr = &actorID - } - headers := executionHeaders{ - runID: workflowID, - parentExecutionID: parentPtr, - sessionID: sessionPtr, - actorID: actorPtr, - } - now := time.Now().UTC() - exec := &types.Execution{ - ExecutionID: executionID, - RunID: workflowID, - ParentExecutionID: parentPtr, - AgentNodeID: nodeID, - ReasonerID: reasonerName, - NodeID: nodeID, - Status: types.ExecutionStatusRunning, - StartedAt: now, - CreatedAt: now, - UpdatedAt: now, - } - agentURL = buildAgentURL(targetNode, target) - - serverlessPayload, err := json.Marshal(buildServerlessPayload(target, exec, headers, req.Input)) - if err != nil { - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusFailed - errorMsg := fmt.Sprintf("failed to encode serverless payload: %v", err) - workflowExecution.ErrorMessage = &errorMsg - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode serverless payload"}) - return - } - agentBody = serverlessPayload - } - - agentReq, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL, bytes.NewBuffer(agentBody)) - if err != nil { - workflowExecution.Status = types.ExecutionStatusFailed - errorMessage := fmt.Sprintf("failed to create agent request: %v", err) - workflowExecution.ErrorMessage = &errorMessage - endTime := time.Now() - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create agent request"}) - return - } - - agentReq.Header.Set("Content-Type", "application/json") - agentReq.Header.Set("X-Workflow-ID", workflowID) - agentReq.Header.Set("X-Execution-ID", executionID) - agentReq.Header.Set("X-AgentField-Request-ID", agentfieldRequestID) - if targetNode.DeploymentType == "serverless" { - agentReq.Header.Set("X-Run-ID", workflowID) - } - if parentWorkflowID != "" { - agentReq.Header.Set("X-Parent-Workflow-ID", parentWorkflowID) - } - if parentExecutionID != "" { - agentReq.Header.Set("X-Parent-Execution-ID", parentExecutionID) - } - if rootWorkflowID != "" { - agentReq.Header.Set("X-Root-Workflow-ID", rootWorkflowID) - } - if sessionID != "" { - agentReq.Header.Set("X-Session-ID", sessionID) - } - if actorID != "" { - agentReq.Header.Set("X-Actor-ID", actorID) - } - if workflowName != "" { - agentReq.Header.Set("X-Workflow-Name", workflowName) - } - if workflowTagsHeader != "" { - agentReq.Header.Set("X-Workflow-Tags", workflowTagsHeader) - } - if callerDID != "" { - agentReq.Header.Set("X-Caller-DID", callerDID) - } - if targetDID != "" { - agentReq.Header.Set("X-Target-DID", targetDID) - } - if agentNodeDID != "" { - agentReq.Header.Set("X-Agent-Node-DID", agentNodeDID) - } - - // Make HTTP request to agent node - resp, err := http.DefaultClient.Do(agentReq) - if err != nil { - // Update execution with error - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusFailed - errorMessage := err.Error() - workflowExecution.ErrorMessage = &errorMessage - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - - // Store execution record - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": fmt.Sprintf("failed to call agent node: %v", err), - }) - return - } - defer resp.Body.Close() - - // Read response from agent node - body, err := io.ReadAll(resp.Body) - if err != nil { - // Update execution with error - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusFailed - errorMsg := "failed to read agent response" - workflowExecution.ErrorMessage = &errorMsg - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - - // Store execution record - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read agent response"}) - return - } - - // Parse agent response - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - logger.Logger.Error(). - Err(err). - Str("agent", nodeID). - Str("agent_url", agentURL). - Msgf("failed to decode agent response: %s", truncateForLog(body)) - // Update execution with error - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusFailed - errorMsg := "failed to parse agent response" - workflowExecution.ErrorMessage = &errorMsg - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - - // Store execution record - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse agent response"}) - return - } - - // Update execution with success - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusSucceeded - workflowExecution.OutputData = body - workflowExecution.OutputSize = len(body) - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - - // Store execution record - // Store execution record - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - // Set response headers - c.Header("X-Workflow-ID", workflowID) - c.Header("X-Execution-ID", executionID) - c.Header("X-AgentField-Request-ID", agentfieldRequestID) - c.Header("X-Agent-Node-ID", nodeID) - c.Header("X-Duration-MS", fmt.Sprintf("%d", duration)) - - // Return successful response - c.JSON(http.StatusOK, ExecuteReasonerResponse{ - Result: result, - NodeID: nodeID, - Duration: duration, - Timestamp: endTime.Format(time.RFC3339), - }) - } -} - -// ExecuteSkillHandler handles execution of skills via AgentField server -func ExecuteSkillHandler(storageProvider storage.StorageProvider) gin.HandlerFunc { - return func(c *gin.Context) { - ctx := c.Request.Context() - startTime := time.Now() - - // Generate AgentField Request ID - agentfieldRequestID := utils.GenerateAgentFieldRequestID() - - // Extract headers - workflowID := c.GetHeader("X-Workflow-ID") - sessionID := c.GetHeader("X-Session-ID") - actorID := c.GetHeader("X-Actor-ID") - parentWorkflowID := c.GetHeader("X-Parent-Workflow-ID") - parentExecutionID := c.GetHeader("X-Parent-Execution-ID") - rootWorkflowID := c.GetHeader("X-Root-Workflow-ID") - workflowName := c.GetHeader("X-Workflow-Name") - workflowTagsHeader := c.GetHeader("X-Workflow-Tags") - callerDID := c.GetHeader("X-Caller-DID") - targetDID := c.GetHeader("X-Target-DID") - agentNodeDID := c.GetHeader("X-Agent-Node-DID") - - // Generate Workflow ID if not provided - if workflowID == "" { - workflowID = utils.GenerateWorkflowID() - } - - // Validate Workflow ID - if !utils.ValidateWorkflowID(workflowID) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workflow_id format"}) - return - } - - // Generate Execution ID - executionID := utils.GenerateExecutionID() - - // Parse skill ID from URL: node_id.skill_name - skillID := c.Param("skill_id") - if skillID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "skill_id is required"}) - return - } - - // Split node_id and skill_name - parts := strings.Split(skillID, ".") - if len(parts) != 2 { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "skill_id must be in format 'node_id.skill_name'", - }) - return - } - - nodeID := parts[0] - skillName := parts[1] - - // Parse request body - var req ExecuteReasonerRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - // Allow empty input for skills that take no parameters. - if req.Input == nil { - req.Input = map[string]interface{}{} - } - - // Find the agent node - targetNode, err := storageProvider.GetAgent(ctx, nodeID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{ - "error": fmt.Sprintf("node '%s' not found", nodeID), - }) - return - } - - // Block agents that are known to be unreachable. - if targetNode.HealthStatus == types.HealthStatusInactive { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": fmt.Sprintf("agent node '%s' is not healthy (status: %s)", nodeID, targetNode.HealthStatus), - }) - return - } - if targetNode.LifecycleStatus == types.AgentStatusOffline { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": fmt.Sprintf("agent node '%s' is offline", nodeID), - }) - return - } - if targetNode.LifecycleStatus == types.AgentStatusPendingApproval { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "agent_pending_approval", - "message": fmt.Sprintf("agent node '%s' is awaiting tag approval and cannot execute", nodeID), - }) - return - } - - // Check if skill exists on the node - skillExists := false - for _, skill := range targetNode.Skills { - if skill.ID == skillName { - skillExists = true - break - } - } - - if !skillExists { - c.JSON(http.StatusNotFound, gin.H{ - "error": fmt.Sprintf("skill '%s' not found on node '%s'", skillName, nodeID), - }) - return - } - - // Create workflow execution record - workflowExecution := &types.WorkflowExecution{ - WorkflowID: workflowID, - ExecutionID: executionID, - AgentFieldRequestID: agentfieldRequestID, - AgentNodeID: nodeID, - ReasonerID: skillName, // For skills, ReasonerID will store skillName - Status: "running", - StartedAt: startTime, - CreatedAt: startTime, - UpdatedAt: startTime, - } - - // Set optional fields - if sessionID != "" { - workflowExecution.SessionID = &sessionID - } - if actorID != "" { - workflowExecution.ActorID = &actorID - } - if parentWorkflowID != "" { - workflowExecution.ParentWorkflowID = &parentWorkflowID - } - if parentExecutionID != "" { - workflowExecution.ParentExecutionID = &parentExecutionID - } - if rootWorkflowID != "" { - workflowExecution.RootWorkflowID = &rootWorkflowID - } - if workflowName != "" { - workflowExecution.WorkflowName = &workflowName - } - - // Parse workflow tags - if workflowTagsHeader != "" { - tags := strings.Split(workflowTagsHeader, ",") - for i, tag := range tags { - tags[i] = strings.TrimSpace(tag) - } - workflowExecution.WorkflowTags = tags - } - - // Store input data - inputJSON, err := json.Marshal(req.Input) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal input"}) - return - } - workflowExecution.InputData = inputJSON - workflowExecution.InputSize = len(inputJSON) - - // Prepare request to agent node with workflow context propagation - agentURL := fmt.Sprintf("%s/skills/%s", targetNode.BaseURL, skillName) - agentReq, err := http.NewRequestWithContext(ctx, http.MethodPost, agentURL, bytes.NewBuffer(inputJSON)) - if err != nil { - workflowExecution.Status = types.ExecutionStatusFailed - errorMessage := fmt.Sprintf("failed to create agent request: %v", err) - workflowExecution.ErrorMessage = &errorMessage - endTime := time.Now() - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create agent request"}) - return - } - - agentReq.Header.Set("Content-Type", "application/json") - agentReq.Header.Set("X-Workflow-ID", workflowID) - agentReq.Header.Set("X-Execution-ID", executionID) - agentReq.Header.Set("X-AgentField-Request-ID", agentfieldRequestID) - if parentWorkflowID != "" { - agentReq.Header.Set("X-Parent-Workflow-ID", parentWorkflowID) - } - if parentExecutionID != "" { - agentReq.Header.Set("X-Parent-Execution-ID", parentExecutionID) - } - if rootWorkflowID != "" { - agentReq.Header.Set("X-Root-Workflow-ID", rootWorkflowID) - } - if sessionID != "" { - agentReq.Header.Set("X-Session-ID", sessionID) - } - if actorID != "" { - agentReq.Header.Set("X-Actor-ID", actorID) - } - if workflowName != "" { - agentReq.Header.Set("X-Workflow-Name", workflowName) - } - if workflowTagsHeader != "" { - agentReq.Header.Set("X-Workflow-Tags", workflowTagsHeader) - } - if callerDID != "" { - agentReq.Header.Set("X-Caller-DID", callerDID) - } - if targetDID != "" { - agentReq.Header.Set("X-Target-DID", targetDID) - } - if agentNodeDID != "" { - agentReq.Header.Set("X-Agent-Node-DID", agentNodeDID) - } - - // Make HTTP request to agent node - resp, err := http.DefaultClient.Do(agentReq) - if err != nil { - // Update execution with error - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusFailed - errorMessage := err.Error() - workflowExecution.ErrorMessage = &errorMessage - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - - // Store execution record - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": fmt.Sprintf("failed to call agent node: %v", err), - }) - return - } - defer resp.Body.Close() - - // Read response from agent node - body, err := io.ReadAll(resp.Body) - if err != nil { - // Update execution with error - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusFailed - errorMsg := "failed to read agent response" - workflowExecution.ErrorMessage = &errorMsg - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - - // Store execution record - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read agent response"}) - return - } - - // Parse agent response - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - // Update execution with error - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusFailed - errorMsg := "failed to parse agent response" - workflowExecution.ErrorMessage = &errorMsg - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - - // Store execution record - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse agent response"}) - return - } - - // Update execution with success - endTime := time.Now() - workflowExecution.Status = types.ExecutionStatusSucceeded - workflowExecution.OutputData = body - workflowExecution.OutputSize = len(body) - workflowExecution.CompletedAt = &endTime - duration := endTime.Sub(startTime).Milliseconds() - workflowExecution.DurationMS = &duration - workflowExecution.UpdatedAt = endTime - - // Store execution record - persistWorkflowExecution(ctx, storageProvider, workflowExecution) - - // Set response headers - c.Header("X-Workflow-ID", workflowID) - c.Header("X-Execution-ID", executionID) - c.Header("X-AgentField-Request-ID", agentfieldRequestID) - c.Header("X-Agent-Node-ID", nodeID) - c.Header("X-Duration-MS", fmt.Sprintf("%d", duration)) - - // Return successful response - c.JSON(http.StatusOK, ExecuteReasonerResponse{ - Result: result, - NodeID: nodeID, - Duration: duration, - Timestamp: endTime.Format(time.RFC3339), - }) - } -} diff --git a/control-plane/internal/handlers/reasoners_test.go b/control-plane/internal/handlers/reasoners_test.go deleted file mode 100644 index a7e1da1f1..000000000 --- a/control-plane/internal/handlers/reasoners_test.go +++ /dev/null @@ -1,378 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/Agent-Field/agentfield/control-plane/internal/events" - "github.com/Agent-Field/agentfield/control-plane/internal/storage" - "github.com/Agent-Field/agentfield/control-plane/pkg/types" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -// reasonerHandlerStorage embeds the StorageProvider interface (its methods are -// nil stubs that panic if called) and overrides only the small set of methods -// that ExecuteReasonerHandler actually invokes. testExecutionStorage is held as -// a named field β€” embedding it would create ambiguous selectors with the -// interface methods. -type reasonerHandlerStorage struct { - storage.StorageProvider - exec *testExecutionStorage - agent *types.AgentNode - getAgentErr error - persisted chan *types.WorkflowExecution - releasePersist chan struct{} -} - -func newReasonerHandlerStorage(agent *types.AgentNode) *reasonerHandlerStorage { - return &reasonerHandlerStorage{ - exec: newTestExecutionStorage(agent), - agent: agent, - } -} - -// Methods used by the tests themselves to inspect persisted state. -func (s *reasonerHandlerStorage) QueryWorkflowExecutions(ctx context.Context, filters types.WorkflowExecutionFilters) ([]*types.WorkflowExecution, error) { - return s.exec.QueryWorkflowExecutions(ctx, filters) -} - -func (s *reasonerHandlerStorage) GetWorkflowExecution(ctx context.Context, executionID string) (*types.WorkflowExecution, error) { - return s.exec.GetWorkflowExecution(ctx, executionID) -} - -// Methods invoked by ExecuteReasonerHandler. -func (s *reasonerHandlerStorage) GetAgent(ctx context.Context, id string) (*types.AgentNode, error) { - if s.getAgentErr != nil { - return nil, s.getAgentErr - } - if s.agent != nil && s.agent.ID == id { - return s.agent, nil - } - return nil, errors.New("agent not found") -} - -func (s *reasonerHandlerStorage) StoreWorkflowExecution(ctx context.Context, execution *types.WorkflowExecution) error { - if execution == nil { - return s.exec.StoreWorkflowExecution(ctx, execution) - } - - cloned := *execution - if s.persisted != nil { - s.persisted <- &cloned - } - if s.releasePersist != nil { - <-s.releasePersist - } - return s.exec.StoreWorkflowExecution(ctx, &cloned) -} - -func (s *reasonerHandlerStorage) CreateExecutionRecord(ctx context.Context, execution *types.Execution) error { - return s.exec.CreateExecutionRecord(ctx, execution) -} - -func (s *reasonerHandlerStorage) GetExecutionEventBus() *events.ExecutionEventBus { - return s.exec.GetExecutionEventBus() -} - -func (s *reasonerHandlerStorage) GetWorkflowExecutionEventBus() *events.EventBus[*types.WorkflowExecutionEvent] { - return s.exec.GetWorkflowExecutionEventBus() -} - -func (s *reasonerHandlerStorage) UpdateWorkflowExecution(ctx context.Context, executionID string, updateFunc func(*types.WorkflowExecution) (*types.WorkflowExecution, error)) error { - return s.exec.UpdateWorkflowExecution(ctx, executionID, updateFunc) -} - -func newReasonerAgent(baseURL string) *types.AgentNode { - return &types.AgentNode{ - ID: "node-1", - BaseURL: baseURL, - Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusReady, - } -} - -func TestExecuteReasonerHandler_MalformedReasonerIDReturnsBadRequest(t *testing.T) { - gin.SetMode(gin.TestMode) - - store := newReasonerHandlerStorage(nil) - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/not-valid", strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusBadRequest, resp.Code) - - var payload map[string]string - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) - require.Contains(t, payload["error"], "node_id.reasoner_name") -} - -func TestExecuteReasonerHandler_NodeLookupAndAvailabilityErrors(t *testing.T) { - gin.SetMode(gin.TestMode) - - tests := []struct { - name string - store *reasonerHandlerStorage - wantCode int - wantErrMsg string - }{ - { - name: "node not found", - store: &reasonerHandlerStorage{ - exec: newTestExecutionStorage(nil), - getAgentErr: errors.New("missing"), - }, - wantCode: http.StatusNotFound, - wantErrMsg: "node 'node-404' not found", - }, - { - name: "inactive node", - store: newReasonerHandlerStorage(&types.AgentNode{ - ID: "node-404", - BaseURL: "http://agent.invalid", - Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, - HealthStatus: types.HealthStatusInactive, - LifecycleStatus: types.AgentStatusReady, - }), - wantCode: http.StatusServiceUnavailable, - wantErrMsg: "is not healthy", - }, - { - name: "offline node", - store: newReasonerHandlerStorage(&types.AgentNode{ - ID: "node-404", - BaseURL: "http://agent.invalid", - Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusOffline, - }), - wantCode: http.StatusServiceUnavailable, - wantErrMsg: "is offline", - }, - { - name: "pending approval node", - store: newReasonerHandlerStorage(&types.AgentNode{ - ID: "node-404", - BaseURL: "http://agent.invalid", - Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, - HealthStatus: types.HealthStatusActive, - LifecycleStatus: types.AgentStatusPendingApproval, - }), - wantCode: http.StatusServiceUnavailable, - wantErrMsg: "agent_pending_approval", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(tt.store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-404.ping", strings.NewReader(`{"input":{}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - - router.ServeHTTP(resp, req) - - require.Equal(t, tt.wantCode, resp.Code) - - var payload map[string]string - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) - require.Contains(t, payload["error"], tt.wantErrMsg) - - records, err := tt.store.QueryWorkflowExecutions(context.Background(), types.WorkflowExecutionFilters{}) - require.NoError(t, err) - require.Empty(t, records) - }) - } -} - -func TestExecuteReasonerHandler_PersistsSuccessfulExecutionBeforeResponse(t *testing.T) { - gin.SetMode(gin.TestMode) - - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/reasoners/ping", r.URL.Path) - - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - defer r.Body.Close() - require.JSONEq(t, `{}`, string(body)) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer agentServer.Close() - - store := newReasonerHandlerStorage(newReasonerAgent(agentServer.URL)) - store.persisted = make(chan *types.WorkflowExecution, 1) - store.releasePersist = make(chan struct{}) - - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(`{}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - - done := make(chan struct{}) - go func() { - router.ServeHTTP(resp, req) - close(done) - }() - - persisted := <-store.persisted - require.Equal(t, string(types.ExecutionStatusSucceeded), persisted.Status) - require.Equal(t, 2, persisted.InputSize) - require.Equal(t, len(`{"ok":true}`), persisted.OutputSize) - require.NotNil(t, persisted.DurationMS) - require.GreaterOrEqual(t, *persisted.DurationMS, int64(0)) - - select { - case <-done: - t.Fatal("handler responded before StoreWorkflowExecution returned") - default: - } - - close(store.releasePersist) - <-done - - require.Equal(t, http.StatusOK, resp.Code) - - var payload ExecuteReasonerResponse - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) - require.Equal(t, "node-1", payload.NodeID) - require.GreaterOrEqual(t, payload.Duration, int64(0)) - - stored, err := store.GetWorkflowExecution(context.Background(), persisted.ExecutionID) - require.NoError(t, err) - require.NotNil(t, stored) - require.Equal(t, string(types.ExecutionStatusSucceeded), stored.Status) - require.JSONEq(t, `{"ok":true}`, string(stored.OutputData)) -} - -func TestExecuteReasonerHandler_PersistsFailedExecutionBeforeResponse(t *testing.T) { - gin.SetMode(gin.TestMode) - - store := newReasonerHandlerStorage(newReasonerAgent("://bad")) - store.persisted = make(chan *types.WorkflowExecution, 1) - store.releasePersist = make(chan struct{}) - - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(`{"input":{"foo":"bar"}}`)) - req.Header.Set("Content-Type", "application/json") - resp := httptest.NewRecorder() - - done := make(chan struct{}) - go func() { - router.ServeHTTP(resp, req) - close(done) - }() - - persisted := <-store.persisted - require.Equal(t, string(types.ExecutionStatusFailed), persisted.Status) - require.NotNil(t, persisted.ErrorMessage) - require.Contains(t, *persisted.ErrorMessage, "failed to create agent request") - require.NotNil(t, persisted.DurationMS) - require.GreaterOrEqual(t, *persisted.DurationMS, int64(0)) - - select { - case <-done: - t.Fatal("handler responded before failed execution was persisted") - default: - } - - close(store.releasePersist) - <-done - - require.Equal(t, http.StatusInternalServerError, resp.Code) - - var payload map[string]string - require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) - require.Contains(t, payload["error"], "failed to create agent request") - - stored, err := store.GetWorkflowExecution(context.Background(), persisted.ExecutionID) - require.NoError(t, err) - require.NotNil(t, stored) - require.Equal(t, string(types.ExecutionStatusFailed), stored.Status) -} - -func TestExecuteReasonerHandler_ServerlessPayloadAndHeaderPropagation(t *testing.T) { - gin.SetMode(gin.TestMode) - - observedHeaders := make(chan http.Header, 1) - observedBody := make(chan map[string]interface{}, 1) - - agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/execute", r.URL.Path) - - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - defer r.Body.Close() - - var payload map[string]interface{} - require.NoError(t, json.Unmarshal(body, &payload)) - - observedHeaders <- r.Header.Clone() - observedBody <- payload - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"serverless":true}`)) - })) - defer agentServer.Close() - - agent := newReasonerAgent(agentServer.URL) - agent.DeploymentType = "serverless" - - store := newReasonerHandlerStorage(agent) - router := gin.New() - router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) - - req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(`{"input":{"message":"hello"}}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Workflow-ID", "wf-serverless") - req.Header.Set("X-Session-ID", "session-1") - req.Header.Set("X-Agent-Node-ID", "caller-node") - resp := httptest.NewRecorder() - - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code) - - headers := <-observedHeaders - require.Equal(t, "wf-serverless", headers.Get("X-Workflow-ID")) - require.Equal(t, "wf-serverless", headers.Get("X-Run-ID")) - require.Equal(t, "session-1", headers.Get("X-Session-ID")) - require.NotEmpty(t, headers.Get("X-Execution-ID")) - - body := <-observedBody - require.Equal(t, "/execute/ping", body["path"]) - require.Equal(t, "ping", body["target"]) - require.Equal(t, "ping", body["reasoner"]) - require.Equal(t, "reasoner", body["type"]) - - input, ok := body["input"].(map[string]interface{}) - require.True(t, ok) - require.Equal(t, "hello", input["message"]) - - execCtx, ok := body["execution_context"].(map[string]interface{}) - require.True(t, ok) - require.Equal(t, "wf-serverless", execCtx["run_id"]) - require.Equal(t, "wf-serverless", execCtx["workflow_id"]) - require.Equal(t, "session-1", execCtx["session_id"]) - require.NotEmpty(t, execCtx["execution_id"]) -} diff --git a/control-plane/internal/handlers/ui/coverage_identity_dashboard_additional_test.go b/control-plane/internal/handlers/ui/coverage_identity_dashboard_additional_test.go deleted file mode 100644 index ca5d2f739..000000000 --- a/control-plane/internal/handlers/ui/coverage_identity_dashboard_additional_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package ui - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/core/domain" - storagepkg "github.com/Agent-Field/agentfield/control-plane/internal/storage" - "github.com/Agent-Field/agentfield/control-plane/pkg/types" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -type identityOverrideStorage struct { - storagepkg.StorageProvider - listExecutionVCsFn func(context.Context, types.VCFilters) ([]*types.ExecutionVCInfo, error) - countExecutionVCsFn func(context.Context, types.VCFilters) (int, error) -} - -func (s *identityOverrideStorage) ListExecutionVCs(ctx context.Context, filters types.VCFilters) ([]*types.ExecutionVCInfo, error) { - if s.listExecutionVCsFn != nil { - return s.listExecutionVCsFn(ctx, filters) - } - return s.StorageProvider.ListExecutionVCs(ctx, filters) -} - -func (s *identityOverrideStorage) CountExecutionVCs(ctx context.Context, filters types.VCFilters) (int, error) { - if s.countExecutionVCsFn != nil { - return s.countExecutionVCsFn(ctx, filters) - } - return s.StorageProvider.CountExecutionVCs(ctx, filters) -} - -func TestIdentityHandlersAdditionalCoverage(t *testing.T) { - gin.SetMode(gin.TestMode) - - t.Run("search credentials parses aliases and limits", func(t *testing.T) { - base := setupTestStorage(t) - store := &identityOverrideStorage{StorageProvider: base} - handler := NewIdentityHandlers(store, nil) - router := gin.New() - handler.RegisterRoutes(router.Group("/api/ui/v1")) - - start := time.Date(2026, 4, 8, 10, 0, 0, 0, time.UTC) - store.listExecutionVCsFn = func(ctx context.Context, filters types.VCFilters) ([]*types.ExecutionVCInfo, error) { - require.Equal(t, 100, filters.Limit) - require.Equal(t, 3, filters.Offset) - require.NotNil(t, filters.WorkflowID) - require.Equal(t, "wf-1", *filters.WorkflowID) - require.NotNil(t, filters.SessionID) - require.Equal(t, "session-1", *filters.SessionID) - require.Nil(t, filters.Status) - require.NotNil(t, filters.IssuerDID) - require.Equal(t, "did:issuer", *filters.IssuerDID) - require.NotNil(t, filters.ExecutionID) - require.Equal(t, "exec-1", *filters.ExecutionID) - require.NotNil(t, filters.CallerDID) - require.Equal(t, "did:caller", *filters.CallerDID) - require.NotNil(t, filters.TargetDID) - require.Equal(t, "did:target", *filters.TargetDID) - require.NotNil(t, filters.AgentNodeID) - require.Equal(t, "node-1", *filters.AgentNodeID) - require.NotNil(t, filters.Search) - require.Equal(t, "needle", *filters.Search) - require.NotNil(t, filters.CreatedAfter) - require.True(t, filters.CreatedAfter.Equal(start)) - require.Nil(t, filters.CreatedBefore) - agentNodeID := "node-1" - workflowName := "wf-one" - return []*types.ExecutionVCInfo{{ - VCID: "vc-1", - ExecutionID: "exec-1", - WorkflowID: "wf-1", - WorkflowName: &workflowName, - SessionID: "session-1", - AgentNodeID: &agentNodeID, - IssuerDID: "did:issuer", - TargetDID: "did:target", - CallerDID: "did:caller", - Status: "completed", - CreatedAt: start.Add(time.Hour), - }}, nil - } - store.countExecutionVCsFn = func(ctx context.Context, filters types.VCFilters) (int, error) { - require.Equal(t, 0, filters.Limit) - require.Equal(t, 0, filters.Offset) - return 4, nil - } - - req := httptest.NewRequest(http.MethodGet, "/api/ui/v1/identity/credentials/search?limit=250&offset=3&workflow_id=wf-1&session_id=session-1&status=all&issuer_did=did:issuer&execution_id=exec-1&caller_did=did:caller&target_did=did:target&agent_id=node-1&q=needle&start_time=2026-04-08T10:00:00Z&end_time=not-a-time", nil) - resp := httptest.NewRecorder() - router.ServeHTTP(resp, req) - - require.Equal(t, http.StatusOK, resp.Code) - body := decodeJSONResponse[map[string]any](t, resp) - require.Equal(t, float64(4), body["total"]) - require.Equal(t, float64(100), body["limit"]) - require.Equal(t, float64(3), body["offset"]) - require.Equal(t, false, body["has_more"]) - }) - - t.Run("search credentials handles list and count errors", func(t *testing.T) { - base := setupTestStorage(t) - store := &identityOverrideStorage{StorageProvider: base} - handler := NewIdentityHandlers(store, nil) - router := gin.New() - handler.RegisterRoutes(router.Group("/api/ui/v1")) - - store.listExecutionVCsFn = func(ctx context.Context, filters types.VCFilters) ([]*types.ExecutionVCInfo, error) { - return nil, errors.New("boom") - } - resp := httptest.NewRecorder() - router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/api/ui/v1/identity/credentials/search?status=pending&limit=5", nil)) - require.Equal(t, http.StatusInternalServerError, resp.Code) - - store.listExecutionVCsFn = func(ctx context.Context, filters types.VCFilters) ([]*types.ExecutionVCInfo, error) { - require.NotNil(t, filters.Status) - require.Equal(t, "pending", *filters.Status) - return []*types.ExecutionVCInfo{}, nil - } - store.countExecutionVCsFn = func(ctx context.Context, filters types.VCFilters) (int, error) { - return 0, errors.New("count failed") - } - resp = httptest.NewRecorder() - router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/api/ui/v1/identity/credentials/search?status=pending&limit=5", nil)) - require.Equal(t, http.StatusInternalServerError, resp.Code) - }) - - t.Run("resolve agent did web helper", func(t *testing.T) { - ls, registry, _, vcService, didWebService, ctx := setupDIDHandlerServices(t) - didWeb := seedDIDHandlerData(t, ls, registry, vcService, didWebService, ctx) - handler := NewIdentityHandlers(ls, didWebService) - - rec := httptest.NewRecorder() - ginCtx, _ := gin.CreateTestContext(rec) - ginCtx.Request = httptest.NewRequest(http.MethodGet, "/", nil) - - require.Equal(t, didWeb, handler.resolveAgentDIDWeb(ginCtx, "node-1")) - require.Empty(t, handler.resolveAgentDIDWeb(ginCtx, "missing")) - }) -} - -func TestDashboardHelperAdditionalCoverage(t *testing.T) { - t.Run("agents summary counts running and storage errors", func(t *testing.T) { - store := &overrideStorage{StorageProvider: setupTestStorage(t)} - agentService := &mockLifecycleAgentService{} - handler := NewDashboardHandler(store, agentService) - - store.listAgentsFn = func(ctx context.Context, filters types.AgentFilters) ([]*types.AgentNode, error) { - return []*types.AgentNode{ - {ID: "agent-1"}, - {ID: "agent-2"}, - }, nil - } - agentService.On("GetAgentStatus", "agent-1").Return(&domain.AgentStatus{IsRunning: true}, nil).Once() - agentService.On("GetAgentStatus", "agent-2").Return(nil, errors.New("offline")).Once() - - summary, err := handler.getAgentsSummary(context.Background()) - require.NoError(t, err) - require.Equal(t, 2, summary.Total) - require.Equal(t, 1, summary.Running) - - store.listAgentsFn = func(ctx context.Context, filters types.AgentFilters) ([]*types.AgentNode, error) { - return nil, errors.New("boom") - } - _, err = handler.getAgentsSummary(context.Background()) - require.Error(t, err) - }) - - t.Run("packages summary covers installed branches and query errors", func(t *testing.T) { - store := &overrideStorage{StorageProvider: setupTestStorage(t)} - handler := NewDashboardHandler(store, &mockLifecycleAgentService{}) - - store.queryAgentPackagesFn = func(ctx context.Context, filters types.PackageFilters) ([]*types.AgentPackage, error) { - return []*types.AgentPackage{ - {ID: "pkg-open"}, - {ID: "pkg-active", ConfigurationSchema: []byte(`{"required":{"token":{"type":"secret"}}}`)}, - {ID: "pkg-missing", ConfigurationSchema: []byte(`{"required":{"token":{"type":"secret"}}}`)}, - }, nil - } - store.getAgentConfigurationFn = func(ctx context.Context, agentID, packageID string) (*types.AgentConfiguration, error) { - if packageID == "pkg-active" { - return &types.AgentConfiguration{Status: types.ConfigurationStatusActive}, nil - } - return nil, errors.New("missing") - } - - summary, err := handler.getPackagesSummary(context.Background()) - require.NoError(t, err) - require.Equal(t, 3, summary.Available) - require.Equal(t, 2, summary.Installed) - - store.queryAgentPackagesFn = func(ctx context.Context, filters types.PackageFilters) ([]*types.AgentPackage, error) { - return nil, errors.New("boom") - } - _, err = handler.getPackagesSummary(context.Background()) - require.Error(t, err) - }) -} diff --git a/control-plane/internal/handlers/ui/identity.go b/control-plane/internal/handlers/ui/identity.go deleted file mode 100644 index 76e981ae0..000000000 --- a/control-plane/internal/handlers/ui/identity.go +++ /dev/null @@ -1,576 +0,0 @@ -package ui - -import ( - "net/http" - "strconv" - "strings" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/logger" - "github.com/Agent-Field/agentfield/control-plane/internal/services" - "github.com/Agent-Field/agentfield/control-plane/internal/storage" - "github.com/Agent-Field/agentfield/control-plane/pkg/types" - "github.com/gin-gonic/gin" -) - -// IdentityHandlers handles identity and credential UI endpoints -type IdentityHandlers struct { - storage storage.StorageProvider - didWebService *services.DIDWebService -} - -// NewIdentityHandlers creates a new identity handlers instance -func NewIdentityHandlers(storage storage.StorageProvider, didWebService *services.DIDWebService) *IdentityHandlers { - return &IdentityHandlers{ - storage: storage, - didWebService: didWebService, - } -} - -// 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"` - DIDWeb string `json:"did_web,omitempty"` - 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 /api/ui/v1/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 /api/ui/v1/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 /api/ui/v1/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, - DIDWeb: h.resolveAgentDIDWeb(c, agent.AgentNodeID), - 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 /api/ui/v1/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, - DIDWeb: h.resolveAgentDIDWeb(c, agentDID.AgentNodeID), - 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 /api/ui/v1/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, - }) -} - -// resolveAgentDIDWeb returns the did:web identifier for an agent, or empty string if unavailable. -func (h *IdentityHandlers) resolveAgentDIDWeb(c *gin.Context, agentID string) string { - if h.didWebService == nil { - return "" - } - result, err := h.didWebService.ResolveDIDByAgentID(c.Request.Context(), agentID) - if err == nil && result != nil && result.DIDDocument != nil { - return h.didWebService.GenerateDIDWeb(agentID) - } - return "" -} - -// RegisterRoutes registers all identity UI routes -func (h *IdentityHandlers) RegisterRoutes(router *gin.RouterGroup) { - identity := router.Group("/identity") - { - // DID Explorer endpoints - identity.GET("/dids/stats", h.GetDIDStats) - identity.GET("/dids/search", h.SearchDIDs) - identity.GET("/agents", h.ListAgents) - identity.GET("/agents/:agent_id/details", h.GetAgentDetails) - - // Credentials endpoints - identity.GET("/credentials/search", h.SearchCredentials) - } -} diff --git a/control-plane/internal/handlers/ui/identity_handlers_test.go b/control-plane/internal/handlers/ui/identity_handlers_test.go deleted file mode 100644 index 6109d15ea..000000000 --- a/control-plane/internal/handlers/ui/identity_handlers_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package ui - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestIdentityHandlersDIDEndpoints(t *testing.T) { - gin.SetMode(gin.TestMode) - - store := setupTestStorage(t) - ctx := context.Background() - now := time.Now().UTC() - require.NoError(t, store.StoreAgentFieldServerDID(ctx, "did:af:server", "did:root:server", []byte("encrypted-seed"), now, now)) - require.NoError(t, store.StoreAgentDID(ctx, "agent-alpha", "did:af:agent-alpha", "did:af:server", "{}", 1)) - require.NoError(t, store.StoreAgentDID(ctx, "agent-beta", "did:af:agent-beta", "did:af:server", "{}", 2)) - require.NoError(t, store.StoreComponentDID(ctx, "reasoner.summarizer", "did:af:reasoner-summarizer", "did:af:agent-alpha", "reasoner", "Summarizer", 11)) - require.NoError(t, store.StoreComponentDID(ctx, "skill.deploy", "did:af:skill-deploy", "did:af:agent-alpha", "skill", "Deploy", 12)) - - handler := NewIdentityHandlers(store, nil) - router := gin.New() - handler.RegisterRoutes(router.Group("/api/ui/v1")) - - statsRecorder := performIdentityRequest(t, router, http.MethodGet, "/api/ui/v1/identity/dids/stats") - require.Equal(t, http.StatusOK, statsRecorder.Code) - var stats DIDStatsResponse - decodeResponseBody(t, statsRecorder, &stats) - require.Equal(t, 2, stats.TotalAgents) - require.Equal(t, 1, stats.TotalReasoners) - require.Equal(t, 1, stats.TotalSkills) - require.Equal(t, 4, stats.TotalDIDs) - - searchRecorder := performIdentityRequest(t, router, http.MethodGet, "/api/ui/v1/identity/dids/search?q=summarizer&type=reasoner&limit=5&offset=0") - require.Equal(t, http.StatusOK, searchRecorder.Code) - var searchResponse struct { - Results []DIDSearchResult `json:"results"` - Total int `json:"total"` - } - decodeResponseBody(t, searchRecorder, &searchResponse) - require.Len(t, searchResponse.Results, 1) - require.Equal(t, 1, searchResponse.Total) - require.Equal(t, "reasoner", searchResponse.Results[0].Type) - require.Equal(t, "Summarizer", searchResponse.Results[0].Name) - require.Equal(t, "11", searchResponse.Results[0].DerivationPath) - - listRecorder := performIdentityRequest(t, router, http.MethodGet, "/api/ui/v1/identity/agents?limit=10&offset=0") - require.Equal(t, http.StatusOK, listRecorder.Code) - var listResponse struct { - Agents []AgentDIDResponse `json:"agents"` - Total int `json:"total"` - } - decodeResponseBody(t, listRecorder, &listResponse) - require.Len(t, listResponse.Agents, 2) - require.Equal(t, 2, listResponse.Total) - agentsByNodeID := make(map[string]AgentDIDResponse, len(listResponse.Agents)) - for _, agent := range listResponse.Agents { - agentsByNodeID[agent.AgentNodeID] = agent - } - require.Contains(t, agentsByNodeID, "agent-alpha") - require.Equal(t, 1, agentsByNodeID["agent-alpha"].ReasonerCount) - require.Equal(t, 1, agentsByNodeID["agent-alpha"].SkillCount) - require.Empty(t, agentsByNodeID["agent-alpha"].DIDWeb) - - detailsRecorder := performIdentityRequest(t, router, http.MethodGet, "/api/ui/v1/identity/agents/agent-alpha/details?limit=1&offset=0") - require.Equal(t, http.StatusOK, detailsRecorder.Code) - var detailsResponse struct { - Agent AgentDIDResponse `json:"agent"` - TotalReasoners int `json:"total_reasoners"` - ReasonersHasMore bool `json:"reasoners_has_more"` - } - decodeResponseBody(t, detailsRecorder, &detailsResponse) - require.Equal(t, "agent-alpha", detailsResponse.Agent.AgentNodeID) - require.Len(t, detailsResponse.Agent.Reasoners, 1) - require.Len(t, detailsResponse.Agent.Skills, 1) - require.Equal(t, 1, detailsResponse.TotalReasoners) - require.False(t, detailsResponse.ReasonersHasMore) - - notFoundRecorder := performIdentityRequest(t, router, http.MethodGet, "/api/ui/v1/identity/agents/agent-missing/details") - require.Equal(t, http.StatusNotFound, notFoundRecorder.Code) - var notFoundBody map[string]string - decodeResponseBody(t, notFoundRecorder, ¬FoundBody) - require.Equal(t, "Agent not found", notFoundBody["error"]) -} - -func TestIdentityHandlersSearchCredentials(t *testing.T) { - gin.SetMode(gin.TestMode) - - store := setupTestStorage(t) - ctx := context.Background() - require.NoError(t, store.StoreExecutionVC(ctx, - "vc-1", "exec-1", "wf-1", "session-1", - "did:af:agent-alpha", "did:target:one", "did:caller:one", - "input-hash-1", "output-hash-1", "completed", - []byte(`{"id":"vc-1"}`), "sig-1", "", 16, - )) - require.NoError(t, store.StoreExecutionVC(ctx, - "vc-2", "exec-2", "wf-2", "session-2", - "did:af:agent-beta", "did:target:two", "did:caller:two", - "input-hash-2", "output-hash-2", "failed", - []byte(`{"id":"vc-2"}`), "sig-2", "", 16, - )) - - handler := NewIdentityHandlers(store, nil) - router := gin.New() - handler.RegisterRoutes(router.Group("/api/ui/v1")) - - verifiedRecorder := performIdentityRequest(t, router, http.MethodGet, "/api/ui/v1/identity/credentials/search?status=verified&workflow_id=wf-1&q=exec-1&start_time=bad-time&limit=10&offset=0") - require.Equal(t, http.StatusOK, verifiedRecorder.Code) - var verifiedResponse struct { - Credentials []VCSearchResult `json:"credentials"` - Total int `json:"total"` - HasMore bool `json:"has_more"` - } - decodeResponseBody(t, verifiedRecorder, &verifiedResponse) - require.Len(t, verifiedResponse.Credentials, 1) - require.Equal(t, 1, verifiedResponse.Total) - require.False(t, verifiedResponse.HasMore) - require.Equal(t, "vc-1", verifiedResponse.Credentials[0].VCID) - require.True(t, verifiedResponse.Credentials[0].Verified) - - failedRecorder := performIdentityRequest(t, router, http.MethodGet, "/api/ui/v1/identity/credentials/search?status=failed&execution_id=exec-2&limit=10&offset=0") - require.Equal(t, http.StatusOK, failedRecorder.Code) - var failedResponse struct { - Credentials []VCSearchResult `json:"credentials"` - Total int `json:"total"` - } - decodeResponseBody(t, failedRecorder, &failedResponse) - require.Len(t, failedResponse.Credentials, 1) - require.Equal(t, 1, failedResponse.Total) - require.Equal(t, "vc-2", failedResponse.Credentials[0].VCID) - require.False(t, failedResponse.Credentials[0].Verified) - } - -func performIdentityRequest(t *testing.T, router *gin.Engine, method, path string) *httptest.ResponseRecorder { - t.Helper() - recorder := httptest.NewRecorder() - request := httptest.NewRequest(method, path, nil) - router.ServeHTTP(recorder, request) - return recorder -} - -func decodeResponseBody(t *testing.T, recorder *httptest.ResponseRecorder, target interface{}) { - t.Helper() - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), target)) -} diff --git a/control-plane/internal/handlers/ui/reasoners.go b/control-plane/internal/handlers/ui/reasoners.go index 5fbfdcf45..af0735883 100644 --- a/control-plane/internal/handlers/ui/reasoners.go +++ b/control-plane/internal/handlers/ui/reasoners.go @@ -408,26 +408,8 @@ func (h *ReasonersHandler) GetExecutionTemplatesHandler(c *gin.Context) { return } - // For now, return mock data since we don't have template storage yet - // TODO: Implement actual template storage and retrieval - templates := []ExecutionTemplate{ - { - ID: "template_001", - Name: "NVDA Analysis", - Description: "Standard NVIDIA stock analysis", - Input: map[string]interface{}{"ticker": "NVDA"}, - CreatedAt: time.Now().Add(-24 * time.Hour), - }, - { - ID: "template_002", - Name: "Tech Stock Analysis", - Description: "General tech stock analysis template", - Input: map[string]interface{}{"ticker": "AAPL", "sector": "technology"}, - CreatedAt: time.Now().Add(-48 * time.Hour), - }, - } - - c.JSON(http.StatusOK, templates) + // Template storage is not implemented yet; return an empty list. + c.JSON(http.StatusOK, []ExecutionTemplate{}) } // SaveExecutionTemplateHandler handles saving new execution templates diff --git a/control-plane/internal/server/apicatalog/catalog_entries.go b/control-plane/internal/server/apicatalog/catalog_entries.go index 93e831038..eea6fd7e3 100644 --- a/control-plane/internal/server/apicatalog/catalog_entries.go +++ b/control-plane/internal/server/apicatalog/catalog_entries.go @@ -10,6 +10,7 @@ func DefaultEntries() []EndpointEntry { {Method: "GET", Path: "/metrics", Group: "health", Summary: "Prometheus metrics", AuthLevel: "public", Tags: []string{"metrics", "monitoring", "prometheus"}}, // --- Discovery --- + {Method: "GET", Path: "/api/v1/reasoners", Group: "discovery", Summary: "List registered reasoners", AuthLevel: "api_key", Tags: []string{"discovery", "reasoners"}}, {Method: "GET", Path: "/api/v1/discovery/capabilities", Group: "discovery", Summary: "Discover agent capabilities, reasoners, and skills", AuthLevel: "api_key", Tags: []string{"discovery", "capabilities", "agents", "reasoners", "skills"}, Parameters: []ParamEntry{ {Name: "agent_ids", In: "query", Type: "string", Desc: "Comma-separated agent IDs to filter"}, @@ -38,13 +39,9 @@ func DefaultEntries() []EndpointEntry { {Method: "POST", Path: "/api/v1/nodes/:node_id/status/refresh", Group: "nodes", Summary: "Force refresh node status", AuthLevel: "api_key", Tags: []string{"nodes", "status", "refresh"}}, {Method: "POST", Path: "/api/v1/nodes/status/bulk", Group: "nodes", Summary: "Bulk node status query", AuthLevel: "api_key", Tags: []string{"nodes", "status", "bulk"}}, {Method: "POST", Path: "/api/v1/nodes/status/refresh", Group: "nodes", Summary: "Refresh all node statuses", AuthLevel: "api_key", Tags: []string{"nodes", "status", "refresh"}}, - {Method: "POST", Path: "/api/v1/nodes/:node_id/start", Group: "nodes", Summary: "Start a node", AuthLevel: "api_key", Tags: []string{"nodes", "lifecycle", "start"}}, - {Method: "POST", Path: "/api/v1/nodes/:node_id/stop", Group: "nodes", Summary: "Stop a node", AuthLevel: "api_key", Tags: []string{"nodes", "lifecycle", "stop"}}, - {Method: "POST", Path: "/api/v1/nodes/:node_id/lifecycle/status", Group: "nodes", Summary: "Update node lifecycle status", AuthLevel: "api_key", Tags: []string{"nodes", "lifecycle", "status"}}, {Method: "PATCH", Path: "/api/v1/nodes/:node_id/status", Group: "nodes", Summary: "Lease-based status update", AuthLevel: "api_key", Tags: []string{"nodes", "status", "lease"}}, {Method: "POST", Path: "/api/v1/nodes/:node_id/actions/ack", Group: "nodes", Summary: "Acknowledge node action", AuthLevel: "api_key", Tags: []string{"nodes", "actions"}}, {Method: "POST", Path: "/api/v1/nodes/:node_id/shutdown", Group: "nodes", Summary: "Graceful node shutdown", AuthLevel: "api_key", Tags: []string{"nodes", "lifecycle", "shutdown"}}, - {Method: "POST", Path: "/api/v1/actions/claim", Group: "nodes", Summary: "Claim pending actions", AuthLevel: "api_key", Tags: []string{"nodes", "actions", "claim"}}, // --- UI: node logs (proxy to agent NDJSON) --- {Method: "GET", Path: "/api/ui/v1/nodes/:nodeId/logs", Group: "ui-nodes", Summary: "Proxy agent process logs (NDJSON tail or follow)", AuthLevel: "api_key", Tags: []string{"ui", "nodes", "logs", "observability"}, @@ -66,13 +63,12 @@ func DefaultEntries() []EndpointEntry { {Method: "POST", Path: "/api/v1/execute/async/:target", Group: "execute", Summary: "Execute a reasoner or skill asynchronously", AuthLevel: "api_key", Tags: []string{"execute", "reasoner", "skill", "async"}, Parameters: []ParamEntry{{Name: "target", In: "path", Required: true, Type: "string", Desc: "Target in format agent_id.reasoner_id or agent_id.skill_id"}}, }, - {Method: "POST", Path: "/api/v1/reasoners/:reasoner_id", Group: "execute", Summary: "Execute a reasoner (legacy endpoint)", AuthLevel: "api_key", Tags: []string{"execute", "reasoner", "legacy"}}, - {Method: "POST", Path: "/api/v1/skills/:skill_id", Group: "execute", Summary: "Execute a skill (legacy endpoint)", AuthLevel: "api_key", Tags: []string{"execute", "skill", "legacy"}}, - // --- Executions --- {Method: "GET", Path: "/api/v1/executions/:execution_id", Group: "executions", Summary: "Get execution status", AuthLevel: "api_key", Tags: []string{"executions", "status"}, Parameters: []ParamEntry{{Name: "execution_id", In: "path", Required: true, Type: "string", Desc: "Execution ID"}}, }, + {Method: "GET", Path: "/api/v1/executions/:execution_id/events", Group: "executions", Summary: "Stream execution events (SSE)", AuthLevel: "api_key", Tags: []string{"executions", "events", "sse"}}, + {Method: "POST", Path: "/api/v1/executions/:execution_id/logs", Group: "executions", Summary: "Submit structured execution logs", AuthLevel: "api_key", Tags: []string{"executions", "logs"}}, {Method: "POST", Path: "/api/v1/executions/batch-status", Group: "executions", Summary: "Batch execution status query", AuthLevel: "api_key", Tags: []string{"executions", "status", "batch"}}, {Method: "POST", Path: "/api/v1/executions/:execution_id/status", Group: "executions", Summary: "Update execution status", AuthLevel: "api_key", Tags: []string{"executions", "status", "update"}}, {Method: "POST", Path: "/api/v1/executions/:execution_id/cancel", Group: "executions", Summary: "Cancel a running execution", AuthLevel: "api_key", Tags: []string{"executions", "cancel"}}, @@ -86,6 +82,7 @@ func DefaultEntries() []EndpointEntry { {Method: "POST", Path: "/api/v1/agents/:node_id/executions/:execution_id/request-approval", Group: "approval", Summary: "Request approval (agent-scoped)", AuthLevel: "api_key", Tags: []string{"approval", "request", "agent-scoped"}}, {Method: "GET", Path: "/api/v1/agents/:node_id/executions/:execution_id/approval-status", Group: "approval", Summary: "Get approval status (agent-scoped)", AuthLevel: "api_key", Tags: []string{"approval", "status", "agent-scoped"}}, {Method: "POST", Path: "/api/v1/webhooks/approval-response", Group: "approval", Summary: "Webhook for approval responses (HMAC-signed)", AuthLevel: "webhook", Tags: []string{"approval", "webhook"}}, + {Method: "POST", Path: "/api/v1/agents/:node_id/executions/:execution_id/awaiter-status", Group: "approval", Summary: "Propagate awaiter status for multi-hop pause", AuthLevel: "api_key", Tags: []string{"executions", "approval", "awaiter"}}, // --- Execution notes --- {Method: "POST", Path: "/api/v1/executions/note", Group: "executions", Summary: "Add an execution note (app.note())", AuthLevel: "api_key", Tags: []string{"executions", "notes"}}, @@ -107,9 +104,9 @@ func DefaultEntries() []EndpointEntry { {Method: "DELETE", Path: "/api/v1/memory/vector/:key", Group: "memory", Summary: "Delete a vector", AuthLevel: "api_key", Tags: []string{"memory", "vector", "delete"}}, // --- DID --- - {Method: "GET", Path: "/api/v1/did/document/:agent_id", Group: "did", Summary: "Get DID document for agent", AuthLevel: "public", Tags: []string{"did", "identity", "document"}}, - {Method: "GET", Path: "/api/v1/did/resolve/:did", Group: "did", Summary: "Resolve a DID to its document", AuthLevel: "public", Tags: []string{"did", "identity", "resolve"}}, - {Method: "GET", Path: "/api/v1/did/issuer-public-keys", Group: "did", Summary: "Get issuer public keys", AuthLevel: "public", Tags: []string{"did", "identity", "keys"}}, + {Method: "GET", Path: "/api/v1/did/document/:did", Group: "did", Summary: "Get DID document", AuthLevel: "public", Tags: []string{"did", "document"}}, + {Method: "GET", Path: "/api/v1/did/resolve/:did", Group: "did", Summary: "Resolve a DID to its document", AuthLevel: "public", Tags: []string{"did", "resolve"}}, + {Method: "GET", Path: "/api/v1/did/issuer-public-key", Group: "did", Summary: "Get issuer public key for offline VC verification", AuthLevel: "public", Tags: []string{"did", "keys"}}, {Method: "GET", Path: "/api/v1/did/workflow/:workflow_id/vc-chain", Group: "did", Summary: "Get VC chain for workflow", AuthLevel: "api_key", Tags: []string{"did", "vc", "workflow", "audit"}}, {Method: "POST", Path: "/api/v1/did/verify-audit", Group: "did", Summary: "Verify exported provenance JSON (VC chain or bare VC)", AuthLevel: "api_key", Tags: []string{"did", "vc", "verify", "audit"}}, @@ -128,20 +125,25 @@ func DefaultEntries() []EndpointEntry { {Method: "GET", Path: "/api/v1/agentic/kb/guide", Group: "agentic-kb", Summary: "Goal-oriented reading path for building agents", AuthLevel: "public", Tags: []string{"kb", "guide", "learning", "onboarding"}}, // --- Settings --- - {Method: "GET", Path: "/api/v1/settings/webhooks", Group: "settings", Summary: "List observability webhooks", AuthLevel: "api_key", Tags: []string{"settings", "webhooks", "observability"}}, - {Method: "POST", Path: "/api/v1/settings/webhooks", Group: "settings", Summary: "Create observability webhook", AuthLevel: "api_key", Tags: []string{"settings", "webhooks", "observability"}}, - {Method: "DELETE", Path: "/api/v1/settings/webhooks/:webhook_id", Group: "settings", Summary: "Delete observability webhook", AuthLevel: "api_key", Tags: []string{"settings", "webhooks"}}, + {Method: "GET", Path: "/api/v1/settings/observability-webhook", Group: "settings", Summary: "Get observability webhook config", AuthLevel: "api_key", Tags: []string{"settings", "webhooks", "observability"}}, + {Method: "POST", Path: "/api/v1/settings/observability-webhook", Group: "settings", Summary: "Set observability webhook config", AuthLevel: "api_key", Tags: []string{"settings", "webhooks", "observability"}}, + {Method: "DELETE", Path: "/api/v1/settings/observability-webhook", Group: "settings", Summary: "Delete observability webhook config", AuthLevel: "api_key", Tags: []string{"settings", "webhooks"}}, + {Method: "GET", Path: "/api/v1/settings/observability-webhook/status", Group: "settings", Summary: "Get observability webhook forwarder status", AuthLevel: "api_key", Tags: []string{"settings", "webhooks", "observability"}}, + {Method: "POST", Path: "/api/v1/settings/observability-webhook/redrive", Group: "settings", Summary: "Redrive failed observability webhook deliveries", AuthLevel: "api_key", Tags: []string{"settings", "webhooks", "observability"}}, + {Method: "GET", Path: "/api/v1/settings/observability-webhook/dlq", Group: "settings", Summary: "List observability webhook dead-letter queue", AuthLevel: "api_key", Tags: []string{"settings", "webhooks", "observability"}}, + {Method: "DELETE", Path: "/api/v1/settings/observability-webhook/dlq", Group: "settings", Summary: "Clear observability webhook dead-letter queue", AuthLevel: "api_key", Tags: []string{"settings", "webhooks"}}, // --- Admin --- - {Method: "GET", Path: "/api/v1/admin/tags/pending", Group: "admin", Summary: "List pending tag approval requests", AuthLevel: "admin", Tags: []string{"admin", "tags", "approval"}}, - {Method: "POST", Path: "/api/v1/admin/tags/approve", Group: "admin", Summary: "Approve a tag request", AuthLevel: "admin", Tags: []string{"admin", "tags", "approval"}}, - {Method: "POST", Path: "/api/v1/admin/tags/reject", Group: "admin", Summary: "Reject a tag request", AuthLevel: "admin", Tags: []string{"admin", "tags", "approval"}}, + {Method: "GET", Path: "/api/v1/admin/agents/pending", Group: "admin", Summary: "List agents pending tag approval", AuthLevel: "admin", Tags: []string{"admin", "tags", "approval"}}, + {Method: "POST", Path: "/api/v1/admin/agents/:agent_id/approve-tags", Group: "admin", Summary: "Approve agent tags", AuthLevel: "admin", Tags: []string{"admin", "tags", "approval"}}, + {Method: "POST", Path: "/api/v1/admin/agents/:agent_id/reject-tags", Group: "admin", Summary: "Reject agent tags", AuthLevel: "admin", Tags: []string{"admin", "tags", "approval"}}, {Method: "GET", Path: "/api/v1/admin/policies", Group: "admin", Summary: "List access policies", AuthLevel: "admin", Tags: []string{"admin", "policies", "access"}}, {Method: "POST", Path: "/api/v1/admin/policies", Group: "admin", Summary: "Create access policy", AuthLevel: "admin", Tags: []string{"admin", "policies", "access"}}, - {Method: "DELETE", Path: "/api/v1/admin/policies/:policy_id", Group: "admin", Summary: "Delete access policy", AuthLevel: "admin", Tags: []string{"admin", "policies"}}, + {Method: "DELETE", Path: "/api/v1/admin/policies/:id", Group: "admin", Summary: "Delete access policy", AuthLevel: "admin", Tags: []string{"admin", "policies"}}, // --- Connector --- - {Method: "GET", Path: "/api/v1/connector/config", Group: "connector", Summary: "Get configuration files", AuthLevel: "connector", Tags: []string{"connector", "config"}}, - {Method: "PUT", Path: "/api/v1/connector/config", Group: "connector", Summary: "Update configuration files", AuthLevel: "connector", Tags: []string{"connector", "config"}}, + {Method: "GET", Path: "/api/v1/connector/manifest", Group: "connector", Summary: "Get connector capabilities manifest", AuthLevel: "connector", Tags: []string{"connector", "manifest"}}, + {Method: "GET", Path: "/api/v1/connector/configs", Group: "connector", Summary: "List stored configuration entries", AuthLevel: "connector", Tags: []string{"connector", "config"}}, + {Method: "PUT", Path: "/api/v1/connector/configs/:key", Group: "connector", Summary: "Create or update a configuration entry", AuthLevel: "connector", Tags: []string{"connector", "config"}}, } } diff --git a/control-plane/internal/server/routes_core.go b/control-plane/internal/server/routes_core.go index 46d2ebe9a..1484d8402 100644 --- a/control-plane/internal/server/routes_core.go +++ b/control-plane/internal/server/routes_core.go @@ -50,44 +50,12 @@ func (s *AgentFieldServer) registerCoreRoutes(agentAPI *gin.RouterGroup) { agentAPI.POST("/nodes/status/bulk", handlers.BulkNodeStatusHandler(s.statusManager, s.storage)) agentAPI.POST("/nodes/status/refresh", handlers.RefreshAllNodeStatusHandler(s.statusManager, s.storage)) - // Enhanced lifecycle management endpoints - agentAPI.POST("/nodes/:node_id/start", handlers.StartNodeHandler(s.statusManager, s.storage)) - agentAPI.POST("/nodes/:node_id/stop", handlers.StopNodeHandler(s.statusManager, s.storage)) - agentAPI.POST("/nodes/:node_id/lifecycle/status", handlers.UpdateLifecycleStatusHandler(s.storage, s.uiService, s.statusManager)) agentAPI.PATCH("/nodes/:node_id/status", handlers.NodeStatusLeaseHandler(s.storage, s.statusManager, s.presenceManager, handlers.DefaultLeaseTTL)) agentAPI.POST("/nodes/:node_id/actions/ack", handlers.NodeActionAckHandler(s.storage, s.presenceManager, handlers.DefaultLeaseTTL)) agentAPI.POST("/nodes/:node_id/shutdown", handlers.NodeShutdownHandler(s.storage, s.statusManager, s.presenceManager)) - agentAPI.POST("/actions/claim", handlers.ClaimActionsHandler(s.storage, s.presenceManager, handlers.DefaultLeaseTTL)) // TODO: Add other node routes (DeleteNode) - // Reasoner and skill execution endpoints (legacy) - // When authorization is enabled, these require the same permission middleware - // as the unified execute endpoints to prevent policy bypass. - if s.config.Features.DID.Authorization.Enabled && s.accessPolicyService != nil && s.didWebService != nil { - legacyReasonerGroup := agentAPI.Group("/reasoners") - legacySkillGroup := agentAPI.Group("/skills") - permConfigLegacy := middleware.PermissionConfig{ - Enabled: true, - DefaultDeny: s.config.Features.DID.Authorization.DefaultDeny, - } - legacyMiddleware := middleware.PermissionCheckMiddleware( - s.accessPolicyService, - s.tagVCVerifier, - s.storage, - s.didWebService, - permConfigLegacy, - ) - legacyReasonerGroup.Use(legacyMiddleware) - legacySkillGroup.Use(legacyMiddleware) - legacyReasonerGroup.POST("/:reasoner_id", handlers.ExecuteReasonerHandler(s.storage)) - legacySkillGroup.POST("/:skill_id", handlers.ExecuteSkillHandler(s.storage)) - logger.Logger.Info().Msg("πŸ”’ Permission checking enabled on legacy reasoner/skill endpoints") - } else { - agentAPI.POST("/reasoners/:reasoner_id", handlers.ExecuteReasonerHandler(s.storage)) - agentAPI.POST("/skills/:skill_id", handlers.ExecuteSkillHandler(s.storage)) - } - // Unified execution endpoints (path-based) // These routes may have permission middleware applied if authorization is enabled. executeGroup := agentAPI.Group("/execute") diff --git a/control-plane/internal/server/routes_did.go b/control-plane/internal/server/routes_did.go index 1bd30c825..cb6c99133 100644 --- a/control-plane/internal/server/routes_did.go +++ b/control-plane/internal/server/routes_did.go @@ -205,8 +205,6 @@ func (s *AgentFieldServer) registerDIDRoutes(agentAPI *gin.RouterGroup) { logger.Logger.Info().Msg("βœ… Registered DIDs endpoint registered (GET /api/v1/registered-dids)") // Issuer public key endpoint β€” agents use this for offline VC signature verification. - // Registered at /did/issuer-public-key (public, semantic path) and - // /admin/public-key (legacy alias for backward compatibility). if s.didService != nil { publicKeyHandler := func(c *gin.Context) { issuerDID, err := s.didService.GetControlPlaneIssuerDID() @@ -240,7 +238,6 @@ func (s *AgentFieldServer) registerDIDRoutes(agentAPI *gin.RouterGroup) { }) } agentAPI.GET("/did/issuer-public-key", publicKeyHandler) - agentAPI.GET("/admin/public-key", publicKeyHandler) // legacy alias logger.Logger.Info().Msg("πŸ”‘ Issuer public key endpoint registered (GET /api/v1/did/issuer-public-key)") } } diff --git a/control-plane/internal/server/routes_ui.go b/control-plane/internal/server/routes_ui.go index f6e6780a8..a75b623fd 100644 --- a/control-plane/internal/server/routes_ui.go +++ b/control-plane/internal/server/routes_ui.go @@ -248,10 +248,6 @@ func (s *AgentFieldServer) registerUIAPI() { vc.POST("/verify", didHandler.VerifyVCHandler) } - // Identity & Trust endpoints (DID Explorer and Credentials) - identityHandler := ui.NewIdentityHandlers(s.storage, s.didWebService) - identityHandler.RegisterRoutes(uiAPI) - // Authorization UI endpoints authorization := uiAPI.Group("/authorization") { diff --git a/control-plane/internal/server/server.go b/control-plane/internal/server/server.go index e2c992add..90a613894 100644 --- a/control-plane/internal/server/server.go +++ b/control-plane/internal/server/server.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "errors" "fmt" "net" "os" @@ -26,23 +25,18 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/observability" "github.com/Agent-Field/agentfield/control-plane/internal/server/apicatalog" "github.com/Agent-Field/agentfield/control-plane/internal/server/knowledgebase" - "github.com/Agent-Field/agentfield/control-plane/internal/server/middleware" "github.com/Agent-Field/agentfield/control-plane/internal/services" _ "github.com/Agent-Field/agentfield/control-plane/internal/sources/all" // register first-party trigger Sources "github.com/Agent-Field/agentfield/control-plane/internal/storage" "github.com/Agent-Field/agentfield/control-plane/internal/utils" - "github.com/Agent-Field/agentfield/control-plane/pkg/adminpb" "github.com/Agent-Field/agentfield/control-plane/pkg/types" "github.com/gin-gonic/gin" "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) // AgentFieldServer represents the core AgentField orchestration service. type AgentFieldServer struct { - adminpb.UnimplementedAdminReasonerServiceServer storage storage.StorageProvider cache storage.CacheProvider Router *gin.Engine @@ -452,7 +446,7 @@ func NewAgentFieldServer(cfg *config.Config) (*AgentFieldServer, error) { handlers.InitConcurrencyLimiter(cfg.AgentField.ExecutionQueue.MaxConcurrentPerAgent) // Initialize execution cleanup service - cleanupService := handlers.NewExecutionCleanupService(storageProvider, cfg.AgentField.ExecutionCleanup) + cleanupService := handlers.NewExecutionCleanupService(storageProvider, payloadStore, cfg.AgentField.ExecutionCleanup) adminPort := cfg.AgentField.Port + 100 if envPort := os.Getenv("AGENTFIELD_ADMIN_GRPC_PORT"); envPort != "" { @@ -624,78 +618,12 @@ func (s *AgentFieldServer) Start() error { } } - if err := s.startAdminGRPCServer(); err != nil { - return fmt.Errorf("failed to start admin gRPC server: %w", err) - } - - // TODO: Implement WebSocket, gRPC // Start HTTP server return s.Router.Run(":" + strconv.Itoa(s.config.AgentField.Port)) } -func (s *AgentFieldServer) startAdminGRPCServer() error { - if s.adminGRPCServer != nil { - return nil - } - - lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.adminGRPCPort)) - if err != nil { - return err - } - - s.adminListener = lis - opts := []grpc.ServerOption{} - if s.config.API.Auth.APIKey != "" { - opts = append(opts, grpc.UnaryInterceptor( - middleware.APIKeyUnaryInterceptor(s.config.API.Auth.APIKey), - )) - } - s.adminGRPCServer = grpc.NewServer(opts...) - adminpb.RegisterAdminReasonerServiceServer(s.adminGRPCServer, s) - - go func() { - if serveErr := s.adminGRPCServer.Serve(lis); serveErr != nil && !errors.Is(serveErr, grpc.ErrServerStopped) { - logger.Logger.Error().Err(serveErr).Msg("admin gRPC server stopped unexpectedly") - } - }() - - logger.Logger.Info().Int("port", s.adminGRPCPort).Msg("admin gRPC server listening") - return nil -} - -// ListReasoners implements the admin gRPC surface for listing registered reasoners. -func (s *AgentFieldServer) ListReasoners(ctx context.Context, _ *adminpb.ListReasonersRequest) (*adminpb.ListReasonersResponse, error) { - nodes, err := s.storage.ListAgents(ctx, types.AgentFilters{}) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list agent nodes: %v", err) - } - - resp := &adminpb.ListReasonersResponse{} - for _, node := range nodes { - if node == nil { - continue - } - for _, reasoner := range node.Reasoners { - resp.Reasoners = append(resp.Reasoners, &adminpb.Reasoner{ - ReasonerId: fmt.Sprintf("%s.%s", node.ID, reasoner.ID), - AgentNodeId: node.ID, - Name: reasoner.ID, - Description: fmt.Sprintf("Reasoner %s from node %s", reasoner.ID, node.ID), - Status: string(node.HealthStatus), - NodeVersion: node.Version, - LastHeartbeat: node.LastHeartbeat.Format(time.RFC3339), - }) - } - } - - return resp, nil -} - // Stop gracefully shuts down the AgentFieldServer. func (s *AgentFieldServer) Stop() error { - if s.adminGRPCServer != nil { - s.adminGRPCServer.GracefulStop() - } if s.adminListener != nil { _ = s.adminListener.Close() } diff --git a/control-plane/internal/server/server_additional_test.go b/control-plane/internal/server/server_additional_test.go index 09a8656d1..ffe1f7c6b 100644 --- a/control-plane/internal/server/server_additional_test.go +++ b/control-plane/internal/server/server_additional_test.go @@ -16,7 +16,6 @@ import ( "github.com/Agent-Field/agentfield/control-plane/internal/config" "github.com/Agent-Field/agentfield/control-plane/internal/services" "github.com/Agent-Field/agentfield/control-plane/internal/storage" - "github.com/Agent-Field/agentfield/control-plane/pkg/adminpb" "github.com/Agent-Field/agentfield/control-plane/pkg/types" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -198,46 +197,6 @@ func TestStartAndStop(t *testing.T) { require.NoError(t, srv.Stop()) } -func TestListReasonersErrorAndSkipsNilNodes(t *testing.T) { - t.Parallel() - - t.Run("returns internal error on storage failure", func(t *testing.T) { - srv := &AgentFieldServer{ - storage: &listAgentsStorage{stubStorage: newStubStorage(), err: fmt.Errorf("boom")}, - } - - resp, err := srv.ListReasoners(context.Background(), &adminpb.ListReasonersRequest{}) - require.Nil(t, resp) - require.Error(t, err) - require.Equal(t, codes.Internal, status.Code(err)) - }) - - t.Run("skips nil nodes and empty reasoners", func(t *testing.T) { - now := time.Date(2026, 4, 8, 14, 0, 0, 0, time.UTC) - srv := &AgentFieldServer{ - storage: &listAgentsStorage{ - stubStorage: newStubStorage(), - nodes: []*types.AgentNode{ - nil, - {ID: "empty", LastHeartbeat: now}, - { - ID: "node-2", - HealthStatus: types.HealthStatusActive, - Version: "2.0.0", - LastHeartbeat: now, - Reasoners: []types.ReasonerDefinition{{ID: "alpha"}}, - }, - }, - }, - } - - resp, err := srv.ListReasoners(context.Background(), &adminpb.ListReasonersRequest{}) - require.NoError(t, err) - require.Len(t, resp.Reasoners, 1) - require.Equal(t, "node-2.alpha", resp.Reasoners[0].ReasonerId) - }) -} - func TestSetupRoutesFilesystemUIAndNoRouteFallback(t *testing.T) { t.Parallel() diff --git a/control-plane/internal/server/server_coverage_additional_test.go b/control-plane/internal/server/server_coverage_additional_test.go index b87417613..42e038b81 100644 --- a/control-plane/internal/server/server_coverage_additional_test.go +++ b/control-plane/internal/server/server_coverage_additional_test.go @@ -189,7 +189,7 @@ func TestStartCoversRecoveryErrorBranches(t *testing.T) { presenceManager: presenceManager, statusManager: statusManager, config: &cfg, - cleanupService: handlers.NewExecutionCleanupService(errStorage, cfg.AgentField.ExecutionCleanup), + cleanupService: handlers.NewExecutionCleanupService(errStorage, nil, cfg.AgentField.ExecutionCleanup), payloadStore: &stubPayloadStore{}, webhookDispatcher: &stubWebhookDispatcher{}, adminGRPCPort: 0, diff --git a/control-plane/internal/server/server_grpc_test.go b/control-plane/internal/server/server_grpc_test.go deleted file mode 100644 index 0cfbd5f5b..000000000 --- a/control-plane/internal/server/server_grpc_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/storage" - "github.com/Agent-Field/agentfield/control-plane/pkg/adminpb" - "github.com/Agent-Field/agentfield/control-plane/pkg/types" - - "github.com/stretchr/testify/require" -) - -func TestListReasonersAggregatesNodes(t *testing.T) { - t.Parallel() - - ctx := context.Background() - tempDir := t.TempDir() - cfg := storage.StorageConfig{ - Mode: "local", - Local: storage.LocalStorageConfig{ - DatabasePath: filepath.Join(tempDir, "agentfield.db"), - KVStorePath: filepath.Join(tempDir, "agentfield.bolt"), - }, - } - - localStore := storage.NewLocalStorage(storage.LocalStorageConfig{}) - if err := localStore.Initialize(ctx, cfg); err != nil { - if strings.Contains(strings.ToLower(err.Error()), "fts5") { - t.Skip("sqlite3 compiled without FTS5; skipping reasoner aggregation test") - } - require.NoError(t, err) - } - t.Cleanup(func() { _ = localStore.Close(ctx) }) - - srv := &AgentFieldServer{storage: localStore} - - schema := json.RawMessage("{}") - node := &types.AgentNode{ - ID: "node-1", - HealthStatus: types.HealthStatusActive, - Version: "1.0.0", - LastHeartbeat: time.Now().UTC(), - Reasoners: []types.ReasonerDefinition{ - {ID: "reason", InputSchema: schema, OutputSchema: schema}, - {ID: "another", InputSchema: schema, OutputSchema: schema}, - }, - } - require.NoError(t, localStore.RegisterAgent(ctx, node)) - - resp, err := srv.ListReasoners(ctx, &adminpb.ListReasonersRequest{}) - require.NoError(t, err) - require.Len(t, resp.Reasoners, 2) - require.Equal(t, "node-1.reason", resp.Reasoners[0].ReasonerId) - require.Equal(t, "node-1", resp.Reasoners[0].AgentNodeId) - require.NotEmpty(t, resp.Reasoners[0].LastHeartbeat) -} diff --git a/control-plane/internal/server/server_routes_test.go b/control-plane/internal/server/server_routes_test.go index dac0ea7e3..76e051a03 100644 --- a/control-plane/internal/server/server_routes_test.go +++ b/control-plane/internal/server/server_routes_test.go @@ -125,8 +125,8 @@ func (s *stubStorage) StoreWorkflowExecutionEvent(ctx context.Context, event *ty func (s *stubStorage) ListWorkflowExecutionEvents(ctx context.Context, executionID string, afterSeq *int64, limit int) ([]*types.WorkflowExecutionEvent, error) { return nil, nil } -func (s *stubStorage) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, error) { - return 0, nil +func (s *stubStorage) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, []string, error) { + return 0, nil, nil } func (s *stubStorage) MarkStaleExecutions(ctx context.Context, staleAfter time.Duration, limit int) (int, error) { return 0, nil diff --git a/control-plane/internal/services/executions_ui_service.go b/control-plane/internal/services/executions_ui_service.go index 46511ca57..3040f0d9d 100644 --- a/control-plane/internal/services/executions_ui_service.go +++ b/control-plane/internal/services/executions_ui_service.go @@ -312,12 +312,6 @@ func (s *ExecutionsUIService) convertToUISummary(exec *types.WorkflowExecution) } } -//nolint:unused // retained for future UI sorting enhancements -func (s *ExecutionsUIService) sortExecutions(executions []ExecutionSummaryForUI, sortBy, sortOrder string) { - // Implementation for sorting executions - // TODO: Implement sorting logic based on sortBy and sortOrder -} - func (s *ExecutionsUIService) groupExecutions(executions []*types.WorkflowExecution, groupBy string) []GroupedExecutionSummary { if groupBy == "none" || groupBy == "" { return []GroupedExecutionSummary{} diff --git a/control-plane/internal/services/executions_ui_service_additional_test.go b/control-plane/internal/services/executions_ui_service_additional_test.go index f2f3b01f7..b80699b05 100644 --- a/control-plane/internal/services/executions_ui_service_additional_test.go +++ b/control-plane/internal/services/executions_ui_service_additional_test.go @@ -245,6 +245,4 @@ func TestExecutionsUIServiceSortGroupsCoversOrders(t *testing.T) { require.Equal(t, tt.wantFirst, groups[0].GroupKey) }) } - - service.sortExecutions(nil, "time", "asc") } diff --git a/control-plane/internal/services/executions_ui_service_sort_additional_test.go b/control-plane/internal/services/executions_ui_service_sort_additional_test.go deleted file mode 100644 index 835f48095..000000000 --- a/control-plane/internal/services/executions_ui_service_sort_additional_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package services - -import "testing" - -func TestExecutionsUIServiceSortExecutionsNoOp(t *testing.T) { - service := &ExecutionsUIService{} - executions := []ExecutionSummaryForUI{ - {ExecutionID: "b"}, - {ExecutionID: "a"}, - } - - service.sortExecutions(executions, "time", "desc") -} diff --git a/control-plane/internal/storage/coverage_sqlite_vector_locks_test.go b/control-plane/internal/storage/coverage_sqlite_vector_locks_test.go index bf43da8b9..a92f13ae7 100644 --- a/control-plane/internal/storage/coverage_sqlite_vector_locks_test.go +++ b/control-plane/internal/storage/coverage_sqlite_vector_locks_test.go @@ -11,33 +11,22 @@ import ( ) func TestLocalLockWrappersAndSQLHelpers(t *testing.T) { - t.Run("local lock wrappers handle success and cancellation", func(t *testing.T) { + t.Run("local lock wrappers reject non-postgres mode", func(t *testing.T) { ls, ctx := setupLocalStorage(t) lock, err := ls.AcquireLock(ctx, "lock-1", time.Second) - require.NoError(t, err) + require.ErrorIs(t, err, errDistributedLocksUnsupported) require.Nil(t, lock) - require.NoError(t, ls.ReleaseLock(ctx, "lock-1")) + require.ErrorIs(t, ls.ReleaseLock(ctx, "lock-1"), errDistributedLocksUnsupported) lock, err = ls.RenewLock(ctx, "lock-1") - require.NoError(t, err) + require.ErrorIs(t, err, errDistributedLocksUnsupported) require.Nil(t, lock) lock, err = ls.GetLockStatus(ctx, "lock-1") - require.NoError(t, err) + require.ErrorIs(t, err, errDistributedLocksUnsupported) require.Nil(t, lock) - - cancelled, cancel := context.WithCancel(ctx) - cancel() - - _, err = ls.AcquireLock(cancelled, "lock-2", time.Second) - require.ErrorIs(t, err, context.Canceled) - require.ErrorIs(t, ls.ReleaseLock(cancelled, "lock-2"), context.Canceled) - _, err = ls.RenewLock(cancelled, "lock-2") - require.ErrorIs(t, err, context.Canceled) - _, err = ls.GetLockStatus(cancelled, "lock-2") - require.ErrorIs(t, err, context.Canceled) }) t.Run("query helpers rebind and stream rows", func(t *testing.T) { diff --git a/control-plane/internal/storage/coverage_storage_clinch_test.go b/control-plane/internal/storage/coverage_storage_clinch_test.go index cacc87988..a4db4f6b9 100644 --- a/control-plane/internal/storage/coverage_storage_clinch_test.go +++ b/control-plane/internal/storage/coverage_storage_clinch_test.go @@ -94,11 +94,11 @@ func TestStorageClinchCleanupOldExecutionsBranches(t *testing.T) { cancelled, cancel := context.WithCancel(ctx) cancel() - deleted, err := ls.CleanupOldExecutions(cancelled, time.Hour, 10) + deleted, _, err := ls.CleanupOldExecutions(cancelled, time.Hour, 10) require.Zero(t, deleted) require.EqualError(t, err, "context cancelled during cleanup old executions: context canceled") - deleted, err = ls.CleanupOldExecutions(ctx, time.Hour, 10) + deleted, _, err = ls.CleanupOldExecutions(ctx, time.Hour, 10) require.NoError(t, err) require.Zero(t, deleted) }) @@ -112,7 +112,7 @@ func TestStorageClinchCleanupOldExecutionsBranches(t *testing.T) { require.NoError(t, rawDB.Close()) ls := &LocalStorage{db: newSQLDatabase(rawDB, "local"), mode: "local"} - deleted, err := ls.CleanupOldExecutions(context.Background(), time.Hour, 1) + deleted, _, err := ls.CleanupOldExecutions(context.Background(), time.Hour, 1) require.Zero(t, deleted) require.ErrorContains(t, err, "failed to query old executions for cleanup") }) diff --git a/control-plane/internal/storage/helpers_test.go b/control-plane/internal/storage/helpers_test.go index 7c0068545..a4849334c 100644 --- a/control-plane/internal/storage/helpers_test.go +++ b/control-plane/internal/storage/helpers_test.go @@ -88,7 +88,23 @@ func TestSQLDatabaseHelpers(t *testing.T) { var txName string require.NoError(t, tx.QueryRow(`select name from items where name = ?`, "gamma").Scan(&txName)) require.Equal(t, "gamma", txName) + + txStmt, err := tx.PrepareContext(context.Background(), `insert into items (name) values (?)`) + require.NoError(t, err) + defer txStmt.Close() + _, err = txStmt.ExecContext(context.Background(), "delta") + require.NoError(t, err) require.NoError(t, tx.Commit()) + + pgDB := newSQLDatabase(rawDB, "postgres") + pgTx, err := pgDB.BeginTx(context.Background(), nil) + require.NoError(t, err) + pgStmt, err := pgTx.PrepareContext(context.Background(), `insert into items (name) values (?)`) + require.NoError(t, err) + defer pgStmt.Close() + _, err = pgStmt.ExecContext(context.Background(), "epsilon") + require.NoError(t, err) + require.NoError(t, pgTx.Commit()) }) } diff --git a/control-plane/internal/storage/local.go b/control-plane/internal/storage/local.go index 47c959758..75f3285e0 100644 --- a/control-plane/internal/storage/local.go +++ b/control-plane/internal/storage/local.go @@ -1324,6 +1324,13 @@ func (ls *LocalStorage) runPostgresMigrations(ctx context.Context) error { description: "Backfill group_id on agent_nodes with id", sql: `UPDATE agent_nodes SET group_id = id WHERE group_id = '' OR group_id IS NULL;`, }, + { + version: "016", + description: "Adds retry_count to workflow_executions for stale-execution auto-retry.", + sql: ` + ALTER TABLE workflow_executions ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; + ALTER TABLE workflow_executions DROP COLUMN IF EXISTS retry_count;`, + }, } for _, m := range migrations { @@ -1699,6 +1706,13 @@ func (ls *LocalStorage) runMigrations() error { description: "Backfill group_id on agent_nodes with id", sql: `UPDATE agent_nodes SET group_id = id WHERE group_id = '' OR group_id IS NULL;`, }, + { + version: "016", + description: "Adds retry_count to workflow_executions for stale-execution auto-retry.", + sql: ` + ALTER TABLE workflow_executions ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; + ALTER TABLE workflow_executions DROP COLUMN IF EXISTS retry_count;`, + }, } // Apply each migration if not already applied @@ -2888,10 +2902,10 @@ func (ls *LocalStorage) QueryWorkflowDAG(ctx context.Context, rootWorkflowID str } // CleanupOldExecutions removes old completed workflow executions based on retention period -func (ls *LocalStorage) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, error) { +func (ls *LocalStorage) CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, []string, error) { // Check context cancellation early if err := ctx.Err(); err != nil { - return 0, fmt.Errorf("context cancelled during cleanup old executions: %w", err) + return 0, nil, fmt.Errorf("context cancelled during cleanup old executions: %w", err) } // Calculate cutoff time @@ -2900,7 +2914,7 @@ func (ls *LocalStorage) CleanupOldExecutions(ctx context.Context, retentionPerio // Query to find old completed executions to delete // Only delete executions that are completed or failed and older than retention period query := ` - SELECT execution_id + SELECT execution_id, input_data, output_data FROM workflow_executions WHERE (status = 'completed' OR status = 'failed') AND completed_at IS NOT NULL @@ -2910,32 +2924,49 @@ func (ls *LocalStorage) CleanupOldExecutions(ctx context.Context, retentionPerio rows, err := ls.db.QueryContext(ctx, query, cutoffTime, batchSize) if err != nil { - return 0, fmt.Errorf("failed to query old executions for cleanup: %w", err) + return 0, nil, fmt.Errorf("failed to query old executions for cleanup: %w", err) } defer rows.Close() var executionIDs []string + payloadURIs := make([]string, 0) + seenPayloadURIs := make(map[string]struct{}) for rows.Next() { var executionID string - if err := rows.Scan(&executionID); err != nil { - return 0, fmt.Errorf("failed to scan execution ID for cleanup: %w", err) + var inputData, outputData []byte + if err := rows.Scan(&executionID, &inputData, &outputData); err != nil { + return 0, nil, fmt.Errorf("failed to scan execution row for cleanup: %w", err) } executionIDs = append(executionIDs, executionID) + for _, uri := range ExtractPayloadURIsFromJSON(inputData) { + if _, exists := seenPayloadURIs[uri]; exists { + continue + } + seenPayloadURIs[uri] = struct{}{} + payloadURIs = append(payloadURIs, uri) + } + for _, uri := range ExtractPayloadURIsFromJSON(outputData) { + if _, exists := seenPayloadURIs[uri]; exists { + continue + } + seenPayloadURIs[uri] = struct{}{} + payloadURIs = append(payloadURIs, uri) + } } if err := rows.Err(); err != nil { - return 0, fmt.Errorf("error after querying old executions for cleanup: %w", err) + return 0, nil, fmt.Errorf("error after querying old executions for cleanup: %w", err) } // If no executions to clean up, return early if len(executionIDs) == 0 { - return 0, nil + return 0, nil, nil } // Begin transaction for atomic cleanup tx, err := ls.db.BeginTx(ctx, nil) if err != nil { - return 0, fmt.Errorf("failed to begin cleanup transaction: %w", err) + return 0, nil, fmt.Errorf("failed to begin cleanup transaction: %w", err) } defer rollbackTx(tx, "cleanupOldExecutions") @@ -2954,20 +2985,20 @@ func (ls *LocalStorage) CleanupOldExecutions(ctx context.Context, retentionPerio result, err := tx.ExecContext(ctx, deleteQuery, args...) if err != nil { - return 0, fmt.Errorf("failed to delete old executions: %w", err) + return 0, nil, fmt.Errorf("failed to delete old executions: %w", err) } deletedCount, err := result.RowsAffected() if err != nil { - return 0, fmt.Errorf("failed to get deleted rows count: %w", err) + return 0, nil, fmt.Errorf("failed to get deleted rows count: %w", err) } // Commit transaction if err := tx.Commit(); err != nil { - return 0, fmt.Errorf("failed to commit cleanup transaction: %w", err) + return 0, nil, fmt.Errorf("failed to commit cleanup transaction: %w", err) } - return int(deletedCount), nil + return int(deletedCount), payloadURIs, nil } // CleanupWorkflow deletes all data related to a specific workflow ID or workflow run identifier diff --git a/control-plane/internal/storage/local_cleanup_test.go b/control-plane/internal/storage/local_cleanup_test.go index 3a2cc0ae5..cb808b1cb 100644 --- a/control-plane/internal/storage/local_cleanup_test.go +++ b/control-plane/internal/storage/local_cleanup_test.go @@ -295,7 +295,7 @@ func TestLocalStorageCleanupOldExecutions(t *testing.T) { insertExecution("old-exec", oldCompleted) insertExecution("recent-exec", recentCompleted) - deleted, err := ls.CleanupOldExecutions(ctx, time.Hour, 10) + deleted, _, err := ls.CleanupOldExecutions(ctx, time.Hour, 10) require.NoError(t, err) require.Equal(t, 1, deleted) diff --git a/control-plane/internal/storage/locks.go b/control-plane/internal/storage/locks.go index 014961924..a7c6c14da 100644 --- a/control-plane/internal/storage/locks.go +++ b/control-plane/internal/storage/locks.go @@ -8,95 +8,41 @@ import ( "github.com/Agent-Field/agentfield/control-plane/pkg/types" - "github.com/boltdb/bolt" "github.com/google/uuid" ) -const ( - locksBucket = "locks" //nolint:unused // Reserved for future use -) +var errDistributedLocksUnsupported = fmt.Errorf("distributed locks are only supported in postgres mode") // AcquireLock attempts to acquire a distributed lock. func (ls *LocalStorage) AcquireLock(ctx context.Context, key string, timeout time.Duration) (*types.DistributedLock, error) { - if ls.mode == "postgres" { - return ls.acquireLockPostgres(ctx, key, timeout) - } - - // Fast-fail if context is already cancelled - if err := ctx.Err(); err != nil { - return nil, err - } - - var lock *types.DistributedLock - err := ls.kvStore.Update(func(tx *bolt.Tx) error { - // Implementation will be added here - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to acquire lock: %w", err) + if ls.mode != "postgres" { + return nil, errDistributedLocksUnsupported } - return lock, nil + return ls.acquireLockPostgres(ctx, key, timeout) } // ReleaseLock releases a distributed lock. func (ls *LocalStorage) ReleaseLock(ctx context.Context, lockID string) error { - if ls.mode == "postgres" { - return ls.releaseLockPostgres(ctx, lockID) - } - - // Fast-fail if context is already cancelled - if err := ctx.Err(); err != nil { - return err + if ls.mode != "postgres" { + return errDistributedLocksUnsupported } - - return ls.kvStore.Update(func(tx *bolt.Tx) error { - // Implementation will be added here - return nil - }) + return ls.releaseLockPostgres(ctx, lockID) } // RenewLock renews a distributed lock to extend its TTL. func (ls *LocalStorage) RenewLock(ctx context.Context, lockID string) (*types.DistributedLock, error) { - if ls.mode == "postgres" { - return ls.renewLockPostgres(ctx, lockID) + if ls.mode != "postgres" { + return nil, errDistributedLocksUnsupported } - - // Fast-fail if context is already cancelled - if err := ctx.Err(); err != nil { - return nil, err - } - - var lock *types.DistributedLock - err := ls.kvStore.Update(func(tx *bolt.Tx) error { - // Implementation will be added here - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to renew lock: %w", err) - } - return lock, nil + return ls.renewLockPostgres(ctx, lockID) } // GetLockStatus retrieves the status of a distributed lock. func (ls *LocalStorage) GetLockStatus(ctx context.Context, key string) (*types.DistributedLock, error) { - if ls.mode == "postgres" { - return ls.getLockStatusPostgres(ctx, key) - } - - // Fast-fail if context is already cancelled - if err := ctx.Err(); err != nil { - return nil, err - } - - var lock *types.DistributedLock - err := ls.kvStore.View(func(tx *bolt.Tx) error { - // Implementation will be added here - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to get lock status: %w", err) + if ls.mode != "postgres" { + return nil, errDistributedLocksUnsupported } - return lock, nil + return ls.getLockStatusPostgres(ctx, key) } func (ls *LocalStorage) acquireLockPostgres(ctx context.Context, key string, timeout time.Duration) (*types.DistributedLock, error) { diff --git a/control-plane/internal/storage/payload_uri.go b/control-plane/internal/storage/payload_uri.go new file mode 100644 index 000000000..20391ed26 --- /dev/null +++ b/control-plane/internal/storage/payload_uri.go @@ -0,0 +1,31 @@ +package storage + +import ( + "encoding/json" + "strings" +) + +const payloadURIPrefix = "payload://" + +// ExtractPayloadURIsFromJSON returns payload:// URIs referenced in a JSON payload field. +// Supports raw URI bytes and JSON-encoded string values. +func ExtractPayloadURIsFromJSON(data []byte) []string { + if len(data) == 0 { + return nil + } + + trimmed := strings.TrimSpace(string(data)) + if strings.HasPrefix(trimmed, payloadURIPrefix) { + return []string{trimmed} + } + + var asString string + if err := json.Unmarshal(data, &asString); err == nil { + asString = strings.TrimSpace(asString) + if strings.HasPrefix(asString, payloadURIPrefix) { + return []string{asString} + } + } + + return nil +} diff --git a/control-plane/internal/storage/payload_uri_test.go b/control-plane/internal/storage/payload_uri_test.go new file mode 100644 index 000000000..4d60659f5 --- /dev/null +++ b/control-plane/internal/storage/payload_uri_test.go @@ -0,0 +1,20 @@ +package storage + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractPayloadURIsFromJSON(t *testing.T) { + t.Parallel() + + rawURI, err := json.Marshal("payload://abc123") + require.NoError(t, err) + + require.Nil(t, ExtractPayloadURIsFromJSON(nil)) + require.Equal(t, []string{"payload://inline"}, ExtractPayloadURIsFromJSON([]byte("payload://inline"))) + require.Equal(t, []string{"payload://abc123"}, ExtractPayloadURIsFromJSON(rawURI)) + require.Nil(t, ExtractPayloadURIsFromJSON([]byte(`{"input":"value"}`))) +} diff --git a/control-plane/internal/storage/sql_helpers.go b/control-plane/internal/storage/sql_helpers.go index 3ed330778..8a4883c4e 100644 --- a/control-plane/internal/storage/sql_helpers.go +++ b/control-plane/internal/storage/sql_helpers.go @@ -126,3 +126,7 @@ func (tx *sqlTx) QueryRowContext(ctx context.Context, query string, args ...inte func (tx *sqlTx) QueryRow(query string, args ...interface{}) *sql.Row { return tx.Tx.QueryRow(tx.rebind(query), args...) } + +func (tx *sqlTx) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return tx.Tx.PrepareContext(ctx, tx.rebind(query)) +} diff --git a/control-plane/internal/storage/stale_retry_postgres_test.go b/control-plane/internal/storage/stale_retry_postgres_test.go new file mode 100644 index 000000000..7685bdd78 --- /dev/null +++ b/control-plane/internal/storage/stale_retry_postgres_test.go @@ -0,0 +1,83 @@ +//go:build integration + +package storage + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + "github.com/stretchr/testify/require" +) + +func TestPostgresMarkAndRetryStaleWorkflowExecutions(t *testing.T) { + ctx, ls, cleanup := setupPostgresTest(t) + defer cleanup() + + now := time.Now().UTC() + staleUpdatedAt := now.Add(-2 * time.Hour) + + exec := &types.WorkflowExecution{ + WorkflowID: "wf-pg-stale", + ExecutionID: "exec-pg-stale-1", + AgentFieldRequestID: "req-pg-1", + AgentNodeID: "agent-pg-1", + ReasonerID: "reason-pg-1", + Status: "running", + StartedAt: now.Add(-3 * time.Hour), + CreatedAt: now.Add(-3 * time.Hour), + UpdatedAt: staleUpdatedAt, + RetryCount: 0, + InputData: json.RawMessage(`{}`), + OutputData: json.RawMessage(`{}`), + } + require.NoError(t, ls.StoreWorkflowExecution(ctx, exec)) + + marked, err := ls.MarkStaleWorkflowExecutions(ctx, time.Hour, 10) + require.NoError(t, err) + require.Equal(t, 1, marked) + + got, err := ls.GetWorkflowExecution(ctx, exec.ExecutionID) + require.NoError(t, err) + require.Equal(t, types.ExecutionStatusTimeout, got.Status) + + // Second stale run eligible for retry (not yet at max retries). + retryExec := &types.WorkflowExecution{ + WorkflowID: "wf-pg-retry", + ExecutionID: "exec-pg-retry-1", + AgentFieldRequestID: "req-pg-2", + AgentNodeID: "agent-pg-1", + ReasonerID: "reason-pg-1", + Status: "running", + StartedAt: now.Add(-3 * time.Hour), + CreatedAt: now.Add(-3 * time.Hour), + UpdatedAt: staleUpdatedAt, + RetryCount: 0, + InputData: json.RawMessage(`{}`), + OutputData: json.RawMessage(`{}`), + } + require.NoError(t, ls.StoreWorkflowExecution(ctx, retryExec)) + require.NoError(t, ls.CreateExecutionRecord(ctx, &types.Execution{ + ExecutionID: retryExec.ExecutionID, + RunID: "run-pg-retry", + AgentNodeID: retryExec.AgentNodeID, + ReasonerID: retryExec.ReasonerID, + NodeID: retryExec.AgentNodeID, + Status: "running", + StartedAt: retryExec.StartedAt, + CreatedAt: retryExec.CreatedAt, + UpdatedAt: retryExec.UpdatedAt, + InputPayload: json.RawMessage(`{}`), + })) + + retried, err := ls.RetryStaleWorkflowExecutions(ctx, time.Hour, 3, 10) + require.NoError(t, err) + require.Contains(t, retried, retryExec.ExecutionID) + + afterRetry, err := ls.GetWorkflowExecution(ctx, retryExec.ExecutionID) + require.NoError(t, err) + require.Equal(t, "pending", afterRetry.Status) + require.Equal(t, 1, afterRetry.RetryCount) +} diff --git a/control-plane/internal/storage/storage.go b/control-plane/internal/storage/storage.go index f10217560..d1d7f3819 100644 --- a/control-plane/internal/storage/storage.go +++ b/control-plane/internal/storage/storage.go @@ -162,8 +162,8 @@ type StorageProvider interface { // Execution cleanup operations // CleanupOldExecutions deletes execution data older than the retention period. // The ctx scopes the cleanup, retentionPeriod sets the age threshold, and batchSize limits each pass. - // Returns the number of cleaned executions or an error if cleanup fails. - CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, error) + // Returns the number of cleaned executions, payload URIs removed from deleted rows, or an error if cleanup fails. + CleanupOldExecutions(ctx context.Context, retentionPeriod time.Duration, batchSize int) (int, []string, error) // MarkStaleExecutions marks long-running executions as stale. // The ctx scopes the update, staleAfter sets the cutoff age, and limit bounds affected rows. // Returns the number marked stale or an error if the update fails. diff --git a/control-plane/migrations/034_workflow_execution_retry_count.sql b/control-plane/migrations/034_workflow_execution_retry_count.sql new file mode 100644 index 000000000..4f1b72206 --- /dev/null +++ b/control-plane/migrations/034_workflow_execution_retry_count.sql @@ -0,0 +1,9 @@ +-- 034_workflow_execution_retry_count.sql +-- Adds retry_count to workflow_executions for stale-execution auto-retry. +-- RetryStaleWorkflowExecutions increments this column when resetting stuck runs. + +-- +goose Up +ALTER TABLE workflow_executions ADD COLUMN IF NOT EXISTS retry_count INTEGER NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE workflow_executions DROP COLUMN IF EXISTS retry_count; diff --git a/control-plane/pkg/adminpb/reasoner_admin.pb.go b/control-plane/pkg/adminpb/reasoner_admin.pb.go deleted file mode 100644 index 339074acc..000000000 --- a/control-plane/pkg/adminpb/reasoner_admin.pb.go +++ /dev/null @@ -1,267 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.6 -// protoc v5.28.2 -// source: proto/admin/reasoner_admin.proto - -package adminpb - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type ListReasonersRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListReasonersRequest) Reset() { - *x = ListReasonersRequest{} - mi := &file_proto_admin_reasoner_admin_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListReasonersRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListReasonersRequest) ProtoMessage() {} - -func (x *ListReasonersRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_admin_reasoner_admin_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListReasonersRequest.ProtoReflect.Descriptor instead. -func (*ListReasonersRequest) Descriptor() ([]byte, []int) { - return file_proto_admin_reasoner_admin_proto_rawDescGZIP(), []int{0} -} - -type Reasoner struct { - state protoimpl.MessageState `protogen:"open.v1"` - ReasonerId string `protobuf:"bytes,1,opt,name=reasoner_id,json=reasonerId,proto3" json:"reasoner_id,omitempty"` - AgentNodeId string `protobuf:"bytes,2,opt,name=agent_node_id,json=agentNodeId,proto3" json:"agent_node_id,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` - Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` - NodeVersion string `protobuf:"bytes,6,opt,name=node_version,json=nodeVersion,proto3" json:"node_version,omitempty"` - LastHeartbeat string `protobuf:"bytes,7,opt,name=last_heartbeat,json=lastHeartbeat,proto3" json:"last_heartbeat,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Reasoner) Reset() { - *x = Reasoner{} - mi := &file_proto_admin_reasoner_admin_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Reasoner) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Reasoner) ProtoMessage() {} - -func (x *Reasoner) ProtoReflect() protoreflect.Message { - mi := &file_proto_admin_reasoner_admin_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Reasoner.ProtoReflect.Descriptor instead. -func (*Reasoner) Descriptor() ([]byte, []int) { - return file_proto_admin_reasoner_admin_proto_rawDescGZIP(), []int{1} -} - -func (x *Reasoner) GetReasonerId() string { - if x != nil { - return x.ReasonerId - } - return "" -} - -func (x *Reasoner) GetAgentNodeId() string { - if x != nil { - return x.AgentNodeId - } - return "" -} - -func (x *Reasoner) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *Reasoner) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -func (x *Reasoner) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -func (x *Reasoner) GetNodeVersion() string { - if x != nil { - return x.NodeVersion - } - return "" -} - -func (x *Reasoner) GetLastHeartbeat() string { - if x != nil { - return x.LastHeartbeat - } - return "" -} - -type ListReasonersResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Reasoners []*Reasoner `protobuf:"bytes,1,rep,name=reasoners,proto3" json:"reasoners,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListReasonersResponse) Reset() { - *x = ListReasonersResponse{} - mi := &file_proto_admin_reasoner_admin_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListReasonersResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListReasonersResponse) ProtoMessage() {} - -func (x *ListReasonersResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_admin_reasoner_admin_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListReasonersResponse.ProtoReflect.Descriptor instead. -func (*ListReasonersResponse) Descriptor() ([]byte, []int) { - return file_proto_admin_reasoner_admin_proto_rawDescGZIP(), []int{2} -} - -func (x *ListReasonersResponse) GetReasoners() []*Reasoner { - if x != nil { - return x.Reasoners - } - return nil -} - -var File_proto_admin_reasoner_admin_proto protoreflect.FileDescriptor - -const file_proto_admin_reasoner_admin_proto_rawDesc = "" + - "\n" + - " proto/admin/reasoner_admin.proto\x12\badmin.v1\"\x16\n" + - "\x14ListReasonersRequest\"\xe7\x01\n" + - "\bReasoner\x12\x1f\n" + - "\vreasoner_id\x18\x01 \x01(\tR\n" + - "reasonerId\x12\"\n" + - "\ragent_node_id\x18\x02 \x01(\tR\vagentNodeId\x12\x12\n" + - "\x04name\x18\x03 \x01(\tR\x04name\x12 \n" + - "\vdescription\x18\x04 \x01(\tR\vdescription\x12\x16\n" + - "\x06status\x18\x05 \x01(\tR\x06status\x12!\n" + - "\fnode_version\x18\x06 \x01(\tR\vnodeVersion\x12%\n" + - "\x0elast_heartbeat\x18\a \x01(\tR\rlastHeartbeat\"I\n" + - "\x15ListReasonersResponse\x120\n" + - "\treasoners\x18\x01 \x03(\v2\x12.admin.v1.ReasonerR\treasoners2h\n" + - "\x14AdminReasonerService\x12P\n" + - "\rListReasoners\x12\x1e.admin.v1.ListReasonersRequest\x1a\x1f.admin.v1.ListReasonersResponseB\x1bZ\x19agentfield/pkg/adminpb;adminpbb\x06proto3" - -var ( - file_proto_admin_reasoner_admin_proto_rawDescOnce sync.Once - file_proto_admin_reasoner_admin_proto_rawDescData []byte -) - -func file_proto_admin_reasoner_admin_proto_rawDescGZIP() []byte { - file_proto_admin_reasoner_admin_proto_rawDescOnce.Do(func() { - file_proto_admin_reasoner_admin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_admin_reasoner_admin_proto_rawDesc), len(file_proto_admin_reasoner_admin_proto_rawDesc))) - }) - return file_proto_admin_reasoner_admin_proto_rawDescData -} - -var file_proto_admin_reasoner_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 3) -var file_proto_admin_reasoner_admin_proto_goTypes = []any{ - (*ListReasonersRequest)(nil), // 0: admin.v1.ListReasonersRequest - (*Reasoner)(nil), // 1: admin.v1.Reasoner - (*ListReasonersResponse)(nil), // 2: admin.v1.ListReasonersResponse -} -var file_proto_admin_reasoner_admin_proto_depIdxs = []int32{ - 1, // 0: admin.v1.ListReasonersResponse.reasoners:type_name -> admin.v1.Reasoner - 0, // 1: admin.v1.AdminReasonerService.ListReasoners:input_type -> admin.v1.ListReasonersRequest - 2, // 2: admin.v1.AdminReasonerService.ListReasoners:output_type -> admin.v1.ListReasonersResponse - 2, // [2:3] is the sub-list for method output_type - 1, // [1:2] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_proto_admin_reasoner_admin_proto_init() } -func file_proto_admin_reasoner_admin_proto_init() { - if File_proto_admin_reasoner_admin_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_admin_reasoner_admin_proto_rawDesc), len(file_proto_admin_reasoner_admin_proto_rawDesc)), - NumEnums: 0, - NumMessages: 3, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_proto_admin_reasoner_admin_proto_goTypes, - DependencyIndexes: file_proto_admin_reasoner_admin_proto_depIdxs, - MessageInfos: file_proto_admin_reasoner_admin_proto_msgTypes, - }.Build() - File_proto_admin_reasoner_admin_proto = out.File - file_proto_admin_reasoner_admin_proto_goTypes = nil - file_proto_admin_reasoner_admin_proto_depIdxs = nil -} diff --git a/control-plane/pkg/adminpb/reasoner_admin_grpc.pb.go b/control-plane/pkg/adminpb/reasoner_admin_grpc.pb.go deleted file mode 100644 index 1336ffa85..000000000 --- a/control-plane/pkg/adminpb/reasoner_admin_grpc.pb.go +++ /dev/null @@ -1,127 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v5.28.2 -// source: proto/admin/reasoner_admin.proto - -package adminpb - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - AdminReasonerService_ListReasoners_FullMethodName = "/admin.v1.AdminReasonerService/ListReasoners" -) - -// AdminReasonerServiceClient is the client API for AdminReasonerService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// AdminReasonerService exposes control-surface operations for reasoning components. -type AdminReasonerServiceClient interface { - // ListReasoners returns the set of registered reasoners and their parent nodes. - ListReasoners(ctx context.Context, in *ListReasonersRequest, opts ...grpc.CallOption) (*ListReasonersResponse, error) -} - -type adminReasonerServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewAdminReasonerServiceClient(cc grpc.ClientConnInterface) AdminReasonerServiceClient { - return &adminReasonerServiceClient{cc} -} - -func (c *adminReasonerServiceClient) ListReasoners(ctx context.Context, in *ListReasonersRequest, opts ...grpc.CallOption) (*ListReasonersResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListReasonersResponse) - err := c.cc.Invoke(ctx, AdminReasonerService_ListReasoners_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// AdminReasonerServiceServer is the server API for AdminReasonerService service. -// All implementations must embed UnimplementedAdminReasonerServiceServer -// for forward compatibility. -// -// AdminReasonerService exposes control-surface operations for reasoning components. -type AdminReasonerServiceServer interface { - // ListReasoners returns the set of registered reasoners and their parent nodes. - ListReasoners(context.Context, *ListReasonersRequest) (*ListReasonersResponse, error) - mustEmbedUnimplementedAdminReasonerServiceServer() -} - -// UnimplementedAdminReasonerServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedAdminReasonerServiceServer struct{} - -func (UnimplementedAdminReasonerServiceServer) ListReasoners(context.Context, *ListReasonersRequest) (*ListReasonersResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListReasoners not implemented") -} -func (UnimplementedAdminReasonerServiceServer) mustEmbedUnimplementedAdminReasonerServiceServer() {} -func (UnimplementedAdminReasonerServiceServer) testEmbeddedByValue() {} - -// UnsafeAdminReasonerServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to AdminReasonerServiceServer will -// result in compilation errors. -type UnsafeAdminReasonerServiceServer interface { - mustEmbedUnimplementedAdminReasonerServiceServer() -} - -func RegisterAdminReasonerServiceServer(s grpc.ServiceRegistrar, srv AdminReasonerServiceServer) { - // If the following call pancis, it indicates UnimplementedAdminReasonerServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&AdminReasonerService_ServiceDesc, srv) -} - -func _AdminReasonerService_ListReasoners_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListReasonersRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(AdminReasonerServiceServer).ListReasoners(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: AdminReasonerService_ListReasoners_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(AdminReasonerServiceServer).ListReasoners(ctx, req.(*ListReasonersRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// AdminReasonerService_ServiceDesc is the grpc.ServiceDesc for AdminReasonerService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var AdminReasonerService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "admin.v1.AdminReasonerService", - HandlerType: (*AdminReasonerServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "ListReasoners", - Handler: _AdminReasonerService_ListReasoners_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "proto/admin/reasoner_admin.proto", -} diff --git a/control-plane/pkg/types/execution.go b/control-plane/pkg/types/execution.go index 9d4e9f965..a6aa3f40a 100644 --- a/control-plane/pkg/types/execution.go +++ b/control-plane/pkg/types/execution.go @@ -121,58 +121,3 @@ func BuildExecutionGraph(executions []*Execution) (map[string]*Execution, []Exec return nodes, edges, roots } - -// LegacyExecutionEndpoints enumerates HTTP handlers that depend on execution data. -// Keeping the list close to the data model helps the migration away from the legacy -// workflow tables by giving us a definitive checklist. -var LegacyExecutionEndpoints = struct { - ExecuteSync string - ExecuteAsync string - ExecuteStatus string - ExecuteBatchState string - RunSnapshot string - ExecutionSnapshot string - RunEvents string - ExecutionEvents string - RunEventStream string - ExecutionStream string - RunCleanup string - - UIWorkflowSummary string - UIWorkflowSummaryFast string - UIWorkflowDag string - UIWorkflowNotes string - UIAgentExecutionList string - UIAgentExecutionDetail string - UIAgentExecutionTimeline string - - UIWorkflowRunList string - UIWorkflowRunDetail string - - UISessionRuns string -}{ - ExecuteSync: "/api/v1/agents/:agent/execute/:target", - ExecuteAsync: "/api/v1/agents/:agent/execute/async/:target", - ExecuteStatus: "/api/v1/agents/:agent/executions/:execution_id", - ExecuteBatchState: "/api/v1/agents/:agent/executions/batch-status", - RunSnapshot: "/api/v1/agents/:agent/workflow/runs/:run_id/snapshot", - ExecutionSnapshot: "/api/v1/agents/:agent/workflow/executions/:execution_id/snapshot", - RunEvents: "/api/v1/agents/:agent/workflow/runs/:run_id/events", - ExecutionEvents: "/api/v1/agents/:agent/workflow/executions/:execution_id/events", - RunEventStream: "/api/v1/agents/:agent/workflow/runs/:run_id/events/stream", - ExecutionStream: "/api/v1/agents/:agent/workflow/executions/:execution_id/events/stream", - RunCleanup: "/api/v1/agents/:agent/workflows/:workflow_id/cleanup", - - UIWorkflowSummary: "/api/ui/v1/workflows/summary", - UIWorkflowSummaryFast: "/api/ui/v1/workflows/summary/optimized", - UIWorkflowDag: "/api/ui/v1/workflows/:workflowId/dag", - UIWorkflowNotes: "/api/ui/v1/workflows/:workflowId/notes/events", - UIAgentExecutionList: "/api/ui/v1/agents/:agentId/executions", - UIAgentExecutionDetail: "/api/ui/v1/agents/:agentId/executions/:executionId", - UIAgentExecutionTimeline: "/api/ui/v1/agents/:agentId/executions/:executionId/timeline", - - UIWorkflowRunList: "/api/ui/v2/workflow-runs", - UIWorkflowRunDetail: "/api/ui/v2/workflow-runs/:run_id", - - UISessionRuns: "/api/ui/v1/sessions/:session_id/workflows", -} diff --git a/control-plane/proto/admin/reasoner_admin.proto b/control-plane/proto/admin/reasoner_admin.proto deleted file mode 100644 index d06b7ad8d..000000000 --- a/control-plane/proto/admin/reasoner_admin.proto +++ /dev/null @@ -1,27 +0,0 @@ -syntax = "proto3"; - -package admin.v1; - -option go_package = "github.com/Agent-Field/agentfield/control-plane/pkg/adminpb;adminpb"; - -// AdminReasonerService exposes control-surface operations for reasoning components. -service AdminReasonerService { - // ListReasoners returns the set of registered reasoners and their parent nodes. - rpc ListReasoners(ListReasonersRequest) returns (ListReasonersResponse); -} - -message ListReasonersRequest {} - -message Reasoner { - string reasoner_id = 1; - string agent_node_id = 2; - string name = 3; - string description = 4; - string status = 5; - string node_version = 6; - string last_heartbeat = 7; -} - -message ListReasonersResponse { - repeated Reasoner reasoners = 1; -} diff --git a/control-plane/web/client/src/components/StepDetail.tsx b/control-plane/web/client/src/components/StepDetail.tsx index 9bc9ed91c..cb5fb84cb 100644 --- a/control-plane/web/client/src/components/StepDetail.tsx +++ b/control-plane/web/client/src/components/StepDetail.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "react-router-dom"; +import { cpFetchV1Json } from "@/lib/cpClient"; import { useStepDetail } from "@/hooks/queries"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; @@ -186,21 +187,13 @@ export function StepDetail({ executionId }: { executionId: string }) { if (!execution?.approval_request_id || approvalBusy) return; setApprovalBusy(true); try { - const res = await fetch("/api/v1/webhooks/approval-response", { + await cpFetchV1Json("/webhooks/approval-response", { method: "POST", - headers: { - "Content-Type": "application/json", - "X-API-Key": localStorage.getItem("agentfield_api_key") ?? "", - }, body: JSON.stringify({ requestId: execution.approval_request_id, decision, }), }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - console.error("Approval failed:", body); - } void queryClient.invalidateQueries({ queryKey: ["step-detail", executionId] }); void queryClient.invalidateQueries({ queryKey: ["run-dag"] }); void queryClient.invalidateQueries({ queryKey: ["executions"] }); diff --git a/control-plane/web/client/src/components/WorkflowDAG/NodeDetailSidebar.tsx b/control-plane/web/client/src/components/WorkflowDAG/NodeDetailSidebar.tsx index 4b996018c..3fc18d468 100644 --- a/control-plane/web/client/src/components/WorkflowDAG/NodeDetailSidebar.tsx +++ b/control-plane/web/client/src/components/WorkflowDAG/NodeDetailSidebar.tsx @@ -12,6 +12,7 @@ import { ExecutionHeader } from "./sections/ExecutionHeader"; import { TechnicalSection } from "./sections/TechnicalSection"; import { TimingSection } from "./sections/TimingSection"; import { useQuery } from "@tanstack/react-query"; +import { listTriggersForNode } from "@/services/triggersApi"; import { Badge } from "../ui/badge"; interface WorkflowNodeData { @@ -267,16 +268,8 @@ function TriggersSection({ nodeId }: { nodeId: string }) { const { data: triggers, isLoading } = useQuery({ queryKey: ["node-triggers", nodeId], queryFn: async () => { - const response = await fetch( - `/api/v1/triggers?target_node_id=${encodeURIComponent(nodeId)}`, - { - headers: { - "X-API-Key": sessionStorage.getItem("apiKey") || "", - }, - } - ); - if (!response.ok) throw new Error("Failed to fetch triggers"); - return response.json(); + const triggers = await listTriggersForNode(nodeId); + return { triggers }; }, enabled: !!nodeId, }); diff --git a/control-plane/web/client/src/components/execution/ExecutionRetryPanel.tsx b/control-plane/web/client/src/components/execution/ExecutionRetryPanel.tsx index b493cac38..09c586209 100644 --- a/control-plane/web/client/src/components/execution/ExecutionRetryPanel.tsx +++ b/control-plane/web/client/src/components/execution/ExecutionRetryPanel.tsx @@ -19,39 +19,47 @@ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; import { Badge } from "../ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { CopyButton } from "../ui/copy-button"; +import { API_V1_BASE, getControlPlaneOrigin } from "@/lib/cpClient"; +import { getGlobalApiKey } from "@/services/api"; interface ExecutionRetryPanelProps { execution: WorkflowExecution; } -function generateCurlCommand(execution: WorkflowExecution): string { - // Use the correct API format: nodeid.reasonerid - const baseUrl = window.location.origin.replace('/ui', ''); // Remove /ui if present +function executeApiUrl(execution: WorkflowExecution): string { const target = `${execution.agent_node_id}.${execution.reasoner_id}`; - const apiUrl = `${baseUrl}/api/v1/execute/${target}`; + return `${getControlPlaneOrigin()}${API_V1_BASE}/execute/${target}`; +} + +function apiKeyHeaderSnippet(): string { + const apiKey = getGlobalApiKey(); + return apiKey ? ` -H "X-API-Key: ${apiKey}" \\\n` : ""; +} +function generateCurlCommand(execution: WorkflowExecution): string { + const apiUrl = executeApiUrl(execution); const payload = { - input: execution.input_data || {} + input: execution.input_data || {}, }; - const curlCommand = `curl -X POST "${apiUrl}" \\ + return `curl -X POST "${apiUrl}" \\ -H "Content-Type: application/json" \\ - -d '${JSON.stringify(payload, null, 2).replace(/'/g, "'\\''")}' \\ +${apiKeyHeaderSnippet()} -d '${JSON.stringify(payload, null, 2).replace(/'/g, "'\\''")}' \\ --silent \\ --show-error`; - - return curlCommand; } function generatePythonCode(execution: WorkflowExecution): string { - const baseUrl = window.location.origin.replace('/ui', ''); // Remove /ui if present - const target = `${execution.agent_node_id}.${execution.reasoner_id}`; - const apiUrl = `${baseUrl}/api/v1/execute/${target}`; - + const apiUrl = executeApiUrl(execution); + const apiKey = getGlobalApiKey(); const payload = { - input: execution.input_data || {} + input: execution.input_data || {}, }; + const headersLines = apiKey + ? ` "Content-Type": "application/json",\n "X-API-Key": "${apiKey}",` + : ` "Content-Type": "application/json",`; + return `import requests import json @@ -59,7 +67,7 @@ url = "${apiUrl}" payload = ${JSON.stringify(payload, null, 2)} headers = { - "Content-Type": "application/json" +${headersLines} } response = requests.post(url, json=payload, headers=headers) @@ -110,20 +118,22 @@ export function ExecutionRetryPanel({ execution }: ExecutionRetryPanelProps) { try { // Use the correct API format: nodeid.reasonerid - const baseUrl = window.location.origin.replace('/ui', ''); - const target = `${execution.agent_node_id}.${execution.reasoner_id}`; - const apiUrl = `${baseUrl}/api/v1/execute/${target}`; - + const apiUrl = executeApiUrl(execution); const payload = { - input: execution.input_data || {} + input: execution.input_data || {}, + }; + const headers: Record = { + "Content-Type": "application/json", }; + const apiKey = getGlobalApiKey(); + if (apiKey) { + headers["X-API-Key"] = apiKey; + } const response = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) + method: "POST", + headers, + body: JSON.stringify(payload), }); if (response.ok) { diff --git a/control-plane/web/client/src/components/triggers/EventDetailPanel.tsx b/control-plane/web/client/src/components/triggers/EventDetailPanel.tsx index 2ed8c3579..8e7cd0a22 100644 --- a/control-plane/web/client/src/components/triggers/EventDetailPanel.tsx +++ b/control-plane/web/client/src/components/triggers/EventDetailPanel.tsx @@ -6,6 +6,7 @@ import { Separator } from "@/components/ui/separator"; import { VerificationCard } from "./VerificationCard"; import { PayloadViewer } from "./PayloadViewer"; import { VCChainCard } from "./VCChainCard"; +import { replayTriggerEvent } from "@/services/triggersApi"; import type { InboundEvent } from "./EventRow"; interface EventDetailPanelProps { @@ -24,16 +25,8 @@ export function EventDetailPanel({ const handleReplay = async () => { setIsReplaying(true); try { - const response = await fetch( - `/api/v1/triggers/${triggerID}/events/${event.id}/replay`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - } - ); - if (response.ok) { - onReplayed?.(); - } + await replayTriggerEvent(triggerID, event.id); + onReplayed?.(); } catch (error) { // Error handled by parent component console.error("Replay failed:", error); diff --git a/control-plane/web/client/src/components/triggers/NewTriggerDialog.tsx b/control-plane/web/client/src/components/triggers/NewTriggerDialog.tsx index 316269d2b..86e9f71d9 100644 --- a/control-plane/web/client/src/components/triggers/NewTriggerDialog.tsx +++ b/control-plane/web/client/src/components/triggers/NewTriggerDialog.tsx @@ -17,6 +17,7 @@ import { import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { createTrigger } from "@/services/triggersApi"; interface SourceCatalogEntry { name: string; @@ -90,24 +91,6 @@ function hintsFor(sourceName: string): SourceHints { return SOURCE_HINTS[sourceName] ?? DEFAULT_HINTS; } -const serverUrl = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - "/api/ui/v1", - "", - ) || window.location.origin; - -async function fetchJson(url: string, init?: RequestInit): Promise { - const res = await fetch(url, { - ...init, - headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text}`); - } - return res.json(); -} - export function NewTriggerDialog({ open, onOpenChange, @@ -193,20 +176,17 @@ export function NewTriggerDialog({ setSubmitting(false); return; } - await fetchJson(`${serverUrl}/api/v1/triggers`, { - method: "POST", - body: JSON.stringify({ - source_name: sourceName, - target_node_id: targetNodeId, - target_reasoner: targetReasoner, - event_types: eventTypes - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - secret_env_var: secretEnv, - config: cfg, - enabled: true, - }), + await createTrigger({ + source_name: sourceName, + target_node_id: targetNodeId, + target_reasoner: targetReasoner, + event_types: eventTypes + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + secret_env_var: secretEnv, + config: cfg, + enabled: true, }); handleOpenChange(false); onCreated(); diff --git a/control-plane/web/client/src/components/triggers/TriggerSheet.tsx b/control-plane/web/client/src/components/triggers/TriggerSheet.tsx index 4ed77d2b4..835f1001d 100644 --- a/control-plane/web/client/src/components/triggers/TriggerSheet.tsx +++ b/control-plane/web/client/src/components/triggers/TriggerSheet.tsx @@ -35,6 +35,7 @@ import { EventRow, type InboundEvent as EventRowInboundEvent, } from "@/components/triggers/EventRow"; +import { listTriggerEvents } from "@/services/triggersApi"; interface Trigger { id: string; @@ -53,7 +54,6 @@ interface Trigger { interface TriggerSheetProps { open: boolean; trigger: Trigger | null; - serverUrl: string; publicUrl: string; busy?: boolean; onOpenChange: (open: boolean) => void; @@ -63,18 +63,6 @@ interface TriggerSheetProps { type InboundEvent = EventRowInboundEvent; -async function fetchJson(url: string, init?: RequestInit): Promise { - const res = await fetch(url, { - ...init, - headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text}`); - } - return res.json() as Promise; -} - function formatDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; @@ -89,7 +77,6 @@ function formatConfig(config: Trigger["config"]): string { export function TriggerSheet({ open, trigger, - serverUrl, publicUrl, busy = false, onOpenChange, @@ -114,17 +101,15 @@ export function TriggerSheet({ if (!triggerId) return; try { setLoadingEvents(true); - const res = await fetchJson<{ events: InboundEvent[] }>( - `${serverUrl}/api/v1/triggers/${triggerId}/events`, - ); - setEvents(res.events || []); + const eventsList = await listTriggerEvents(triggerId); + setEvents(eventsList as InboundEvent[]); setEventError(null); } catch (e) { setEventError(e instanceof Error ? e.message : String(e)); } finally { setLoadingEvents(false); } - }, [serverUrl, triggerId]); + }, [triggerId]); useEffect(() => { if (open && triggerId) void refreshEvents(); diff --git a/control-plane/web/client/src/hooks/useSSE.ts b/control-plane/web/client/src/hooks/useSSE.ts index fd64092bd..100547b1f 100644 --- a/control-plane/web/client/src/hooks/useSSE.ts +++ b/control-plane/web/client/src/hooks/useSSE.ts @@ -382,16 +382,3 @@ export function useNodeUnifiedStatusSSE(_nodeId: string | null) { }); } -/** - * MCP health stream for a node (reserved for control-plane MCP SSE; no-op URL until wired). - */ -export function useMCPHealthSSE(nodeId: string | null) { - const url = nodeId ? `/api/ui/v1/nodes/${nodeId}/mcp/health/stream` : null; - return useSSE(url, { - eventTypes: ['mcp_health_changed', 'mcp_server_status'], - autoReconnect: false, - maxReconnectAttempts: 0, - reconnectDelayMs: 5000, - exponentialBackoff: false, - }); -} diff --git a/control-plane/web/client/src/lib/cpClient.ts b/control-plane/web/client/src/lib/cpClient.ts new file mode 100644 index 000000000..70e4d0d44 --- /dev/null +++ b/control-plane/web/client/src/lib/cpClient.ts @@ -0,0 +1,183 @@ +import { getGlobalAdminToken, getGlobalApiKey } from '@/services/api'; + +/** UI-facing control plane API (embedded admin). */ +export const API_UI_V1_BASE = + import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; + +/** UI v2 endpoints (workflow runs, etc.). */ +export const API_UI_V2_BASE = + import.meta.env.VITE_API_V2_BASE_URL || '/api/ui/v2'; + +/** Public / integrator REST API. */ +export const API_V1_BASE = '/api/v1'; + +/** Origin for absolute `/api/v1/...` URLs (no trailing slash). */ +export function getControlPlaneOrigin(): string { + const fromEnv = API_UI_V1_BASE.replace(/\/api\/ui\/v1\/?$/, ''); + if (fromEnv && fromEnv !== API_UI_V1_BASE) { + return fromEnv.replace(/\/$/, ''); + } + if (typeof window !== 'undefined') { + return window.location.origin; + } + return ''; +} + +export function buildAuthHeaders( + existing?: HeadersInit, + options?: { json?: boolean }, +): Headers { + const headers = new Headers(existing); + const apiKey = getGlobalApiKey(); + if (apiKey) { + headers.set('X-API-Key', apiKey); + } + const adminToken = getGlobalAdminToken(); + if (adminToken) { + headers.set('X-Admin-Token', adminToken); + } + if (options?.json !== false && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + return headers; +} + +export class CpFetchError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'CpFetchError'; + this.status = status; + } +} + +async function parseErrorMessage(response: Response): Promise { + const text = await response.text().catch(() => ''); + if (text.trim()) { + try { + const parsed: unknown = JSON.parse(text); + if (typeof parsed === 'string' && parsed.trim()) { + return `HTTP ${response.status}: ${parsed}`; + } + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as { message?: string }).message === 'string' + ) { + return `HTTP ${response.status}: ${(parsed as { message: string }).message}`; + } + } catch { + return `HTTP ${response.status}: ${text.slice(0, 200)}`; + } + } + return `HTTP ${response.status}`; +} + +export type CpFetchJsonOptions = RequestInit & { + timeout?: number; + /** When false, omit default Content-Type (blob/stream GETs). */ + authJson?: boolean; +}; + +async function cpFetchWithTimeout( + url: string, + options: CpFetchJsonOptions = {}, +): Promise { + const { timeout = 10000, authJson, ...fetchOptions } = options; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const headers = buildAuthHeaders(fetchOptions.headers, { + json: authJson ?? fetchOptions.body != null, + }); + + try { + const response = await fetch(url, { + ...fetchOptions, + headers, + signal: fetchOptions.signal ?? controller.signal, + }); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Request timeout after ${timeout}ms`); + } + throw error; + } +} + +export async function cpFetchJson( + path: string, + options: CpFetchJsonOptions = {}, + baseUrl: string = API_UI_V1_BASE, +): Promise { + const url = path.startsWith('http') + ? path + : `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; + const response = await cpFetchWithTimeout(url, options); + if (!response.ok) { + throw new CpFetchError(await parseErrorMessage(response), response.status); + } + return response.json() as Promise; +} + +export async function cpFetchUiV1Json( + path: string, + options?: CpFetchJsonOptions, +): Promise { + return cpFetchJson(path, options, API_UI_V1_BASE); +} + +export async function cpFetchUiV2Json( + path: string, + options?: CpFetchJsonOptions, +): Promise { + return cpFetchJson(path, options, API_UI_V2_BASE); +} + +export async function cpFetchV1Json( + path: string, + options?: CpFetchJsonOptions, +): Promise { + return cpFetchJson(path, options, API_V1_BASE); +} + +/** Authenticated fetch returning the raw Response (blobs, NDJSON, etc.). */ +export async function cpFetchRaw( + path: string, + options: CpFetchJsonOptions = {}, + baseUrl: string = API_UI_V1_BASE, +): Promise { + const url = path.startsWith('http') + ? path + : `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; + const response = await cpFetchWithTimeout(url, { + ...options, + authJson: false, + }); + if (!response.ok) { + throw new CpFetchError(await parseErrorMessage(response), response.status); + } + return response; +} + +/** Trigger browser download from an authenticated GET. */ +export async function cpDownloadBlob( + path: string, + filename: string, + baseUrl: string = API_UI_V1_BASE, +): Promise { + const response = await cpFetchRaw(path, { method: 'GET' }, baseUrl); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/control-plane/web/client/src/lib/governanceProbe.ts b/control-plane/web/client/src/lib/governanceProbe.ts index 06fc43690..3aacf1aa2 100644 --- a/control-plane/web/client/src/lib/governanceProbe.ts +++ b/control-plane/web/client/src/lib/governanceProbe.ts @@ -1,21 +1,10 @@ -import { getGlobalAdminToken, getGlobalApiKey } from "@/services/api"; - -const API_BASE = "/api/v1"; - -function buildHeaders(): HeadersInit { - const headers: Record = { "Content-Type": "application/json" }; - const apiKey = getGlobalApiKey(); - if (apiKey) headers["X-Api-Key"] = apiKey; - const admin = getGlobalAdminToken(); - if (admin) headers["X-Admin-Token"] = admin; - return headers; -} +import { API_V1_BASE, buildAuthHeaders } from "@/lib/cpClient"; /** True when `/api/v1/admin/policies` exists (authorization feature enabled on server). */ export async function areGovernanceAdminRoutesAvailable(): Promise { - const res = await fetch(`${API_BASE}/admin/policies`, { + const res = await fetch(`${API_V1_BASE}/admin/policies`, { method: "GET", - headers: buildHeaders(), + headers: buildAuthHeaders(), }); return res.status !== 404; } diff --git a/control-plane/web/client/src/pages/IntegrationsPage.tsx b/control-plane/web/client/src/pages/IntegrationsPage.tsx index 8b4c8d132..f0e41ee62 100644 --- a/control-plane/web/client/src/pages/IntegrationsPage.tsx +++ b/control-plane/web/client/src/pages/IntegrationsPage.tsx @@ -21,19 +21,12 @@ import { PageHeader } from "@/components/PageHeader"; import { SourceIcon } from "@/components/triggers/SourceIcon"; import { NewTriggerDialog } from "@/components/triggers/NewTriggerDialog"; import { cn } from "@/lib/utils"; - -interface SourceCatalogEntry { - name: string; - kind: "http" | "loop" | string; - secret_required: boolean; - config_schema: Record; -} - -interface Trigger { - id: string; - source_name: string; - enabled: boolean; -} +import { + listTriggerSources, + listTriggers, + type SourceCatalogEntry, + type Trigger, +} from "@/services/triggersApi"; type SourceCategory = "Provider" | "Schedule" | "Generic"; @@ -103,24 +96,6 @@ const CATEGORY_RANK: Record = { Generic: 2, }; -const serverUrl = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - "/api/ui/v1", - "", - ) || window.location.origin; - -async function fetchJson(url: string, init?: RequestInit): Promise { - const res = await fetch(url, { - ...init, - headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text}`); - } - return res.json() as Promise; -} - function metaFor(source: SourceCatalogEntry): SourceMeta { return ( SOURCE_META[source.name] ?? { @@ -150,14 +125,9 @@ export function IntegrationsPage() { const load = useCallback(async () => { try { setLoading(true); - const [s, t] = await Promise.all([ - fetchJson<{ sources: SourceCatalogEntry[] }>( - `${serverUrl}/api/v1/sources`, - ), - fetchJson<{ triggers: Trigger[] }>(`${serverUrl}/api/v1/triggers`), - ]); - setSources(s.sources || []); - setTriggers(t.triggers || []); + const [s, t] = await Promise.all([listTriggerSources(), listTriggers()]); + setSources(s); + setTriggers(t); setError(null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); diff --git a/control-plane/web/client/src/pages/NewDashboardPage.test.tsx b/control-plane/web/client/src/pages/NewDashboardPage.test.tsx index 0a1ac3191..ce0cc72ac 100644 --- a/control-plane/web/client/src/pages/NewDashboardPage.test.tsx +++ b/control-plane/web/client/src/pages/NewDashboardPage.test.tsx @@ -49,7 +49,8 @@ vi.mock("@/components/dashboard/DashboardActiveWorkload", () => ({ // Import mocks for manipulation import { useRuns, useLLMHealth, useQueueStatus, useAgents } from "@/hooks/queries"; -import { getDashboardSummary, getTriggerMetrics } from "@/services/dashboardService"; +import { getDashboardSummary } from "@/services/dashboardService"; +import { getTriggerMetrics } from "@/services/triggersApi"; const queryClient = new QueryClient({ defaultOptions: { diff --git a/control-plane/web/client/src/pages/NewDashboardPage.tsx b/control-plane/web/client/src/pages/NewDashboardPage.tsx index 48b44901d..030a0fd36 100644 --- a/control-plane/web/client/src/pages/NewDashboardPage.tsx +++ b/control-plane/web/client/src/pages/NewDashboardPage.tsx @@ -32,7 +32,8 @@ import { shortRunIdForDashboard as shortRunId } from "@/components/dashboard/das import { useRuns } from "@/hooks/queries"; import { useLLMHealth, useQueueStatus } from "@/hooks/queries"; import { useAgents } from "@/hooks/queries"; -import { getDashboardSummary, getTriggerMetrics } from "@/services/dashboardService"; +import { getDashboardSummary } from "@/services/dashboardService"; +import { getTriggerMetrics } from "@/services/triggersApi"; import { formatRelativeTime } from "@/utils/dateFormat"; import { getStatusTheme, diff --git a/control-plane/web/client/src/pages/NewSettingsPage.tsx b/control-plane/web/client/src/pages/NewSettingsPage.tsx index fbcc5ea70..e86b21e7f 100644 --- a/control-plane/web/client/src/pages/NewSettingsPage.tsx +++ b/control-plane/web/client/src/pages/NewSettingsPage.tsx @@ -51,6 +51,7 @@ import { type ObservabilityForwarderStatus, type ObservabilityWebhookRequest, } from "@/services/observabilityWebhookApi"; +import { cpDownloadBlob, cpFetchV1Json } from "@/lib/cpClient"; import { getDIDSystemStatus } from "@/services/didApi"; import { getNodeLogProxySettings, @@ -796,12 +797,9 @@ function IdentityTab() { // The /did/status endpoint only returns operational status β€” the DID itself // lives at /api/v1/did/agentfield-server (note: v1, not ui/v1). const statusFetch = getDIDSystemStatus().catch(() => null); - const serverUrl = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace("/api/ui/v1", "") || - window.location.origin; - const serverDidFetch = fetch(`${serverUrl}/api/v1/did/agentfield-server`) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null); + const serverDidFetch = cpFetchV1Json<{ agentfield_server_did?: string }>( + "/did/agentfield-server", + ).catch(() => null); Promise.all([statusFetch, serverDidFetch]).then(([statusRes, serverDidRes]) => { if (cancelled) return; @@ -832,7 +830,11 @@ function IdentityTab() { }; const handleExportCredentials = async () => { - window.open("/api/ui/v1/did/export/vcs", "_blank"); + try { + await cpDownloadBlob("/did/export/vcs", "agentfield-vcs-export.json"); + } catch (error) { + console.error("Failed to export credentials:", error); + } }; return ( @@ -926,48 +928,6 @@ function IdentityTab() { ); } -// --------------------------------------------------------------------------- -// Tab: About -// --------------------------------------------------------------------------- - -function AboutTab() { - const serverUrl = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace("/api/ui/v1", "") || - window.location.origin; - - return ( - - - About AgentField - - Platform version and runtime information. - - - -
- Version - 0.1.63 -
- -
- Server URL - {serverUrl} -
- -
- Storage Mode - Local (SQLite) -
- -
- UI Base Path - {import.meta.env.VITE_BASE_PATH || "/ui"} -
-
-
- ); -} - // --------------------------------------------------------------------------- // Tab: Agent logs (control plane β†’ node log proxy limits) // --------------------------------------------------------------------------- @@ -1229,9 +1189,6 @@ export function NewSettingsPage() { Identity - - About - @@ -1249,10 +1206,6 @@ export function NewSettingsPage() { - - - - ); diff --git a/control-plane/web/client/src/pages/TriggersPage.tsx b/control-plane/web/client/src/pages/TriggersPage.tsx index 43020cec5..c03a62674 100644 --- a/control-plane/web/client/src/pages/TriggersPage.tsx +++ b/control-plane/web/client/src/pages/TriggersPage.tsx @@ -57,57 +57,17 @@ import { NewTriggerDialog } from "@/components/triggers/NewTriggerDialog"; import { SourceIcon } from "@/components/triggers/SourceIcon"; import { TriggerSheet } from "@/components/triggers/TriggerSheet"; import { cn } from "@/lib/utils"; +import { + deleteTrigger as deleteTriggerApi, + getTriggerIngestUrl, + listTriggerSources, + listTriggers, + updateTrigger as updateTriggerApi, + type SourceCatalogEntry, + type Trigger, +} from "@/services/triggersApi"; -export interface SourceCatalogEntry { - name: string; - kind: "http" | "loop" | string; - secret_required: boolean; - config_schema: Record; -} - -export interface Trigger { - id: string; - source_name: string; - config: Record | string | null; - secret_env_var: string; - target_node_id: string; - target_reasoner: string; - event_types?: string[] | null; - managed_by: "code" | "ui"; - enabled: boolean; - created_at: string; - updated_at: string; - // Per-trigger 24h activity (populated by the backend on the list response). - // Optional so older API responses degrade gracefully β€” UI shows "β€”". - event_count_24h?: number; - dispatch_success_24h?: number; - dispatch_failed_24h?: number; - last_event_at?: string | null; - /** 24-element histogram, index 0 = oldest hour, index 23 = current hour. */ - dispatch_buckets_24h?: number[]; -} - -const serverUrl = - (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - "/api/ui/v1", - "", - ) || window.location.origin; - -async function fetchJson(url: string, init?: RequestInit): Promise { - const res = await fetch(url, { - ...init, - headers: { "Content-Type": "application/json", ...(init?.headers || {}) }, - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text}`); - } - return res.json() as Promise; -} - -function publicIngestUrl(triggerId: string): string { - return `${serverUrl}/sources/${triggerId}`; -} +export type { SourceCatalogEntry, Trigger }; function shortTriggerId(id: string): string { if (id.length <= 8) return id; @@ -400,14 +360,9 @@ export function TriggersPage() { const refresh = useCallback(async () => { try { setLoading(true); - const [s, t] = await Promise.all([ - fetchJson<{ sources: SourceCatalogEntry[] }>( - `${serverUrl}/api/v1/sources`, - ), - fetchJson<{ triggers: Trigger[] }>(`${serverUrl}/api/v1/triggers`), - ]); - setSources(s.sources || []); - setTriggers(t.triggers || []); + const [s, t] = await Promise.all([listTriggerSources(), listTriggers()]); + setSources(s); + setTriggers(t); setError(null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -480,10 +435,7 @@ export function TriggersPage() { async function updateTrigger(triggerId: string, patch: Partial) { try { setBusyTriggerId(triggerId); - await fetchJson(`${serverUrl}/api/v1/triggers/${triggerId}`, { - method: "PUT", - body: JSON.stringify(patch), - }); + await updateTriggerApi(triggerId, patch); await refresh(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -496,9 +448,7 @@ export function TriggersPage() { if (trigger.managed_by === "code") return; try { setBusyTriggerId(trigger.id); - await fetchJson(`${serverUrl}/api/v1/triggers/${trigger.id}`, { - method: "DELETE", - }); + await deleteTriggerApi(trigger.id); closeTrigger(); await refresh(); } catch (e) { @@ -510,7 +460,7 @@ export function TriggersPage() { } function copyTriggerUrl(trigger: Trigger) { - void navigator.clipboard.writeText(publicIngestUrl(trigger.id)); + void navigator.clipboard.writeText(getTriggerIngestUrl(trigger.id)); } const totalCount = triggers.length; @@ -715,8 +665,7 @@ export function TriggersPage() { { if (!open) closeTrigger(); diff --git a/control-plane/web/client/src/services/accessPoliciesApi.ts b/control-plane/web/client/src/services/accessPoliciesApi.ts index f49ea2bb7..48f8506a2 100644 --- a/control-plane/web/client/src/services/accessPoliciesApi.ts +++ b/control-plane/web/client/src/services/accessPoliciesApi.ts @@ -3,9 +3,9 @@ * API client for tag-based access policy admin endpoints */ -import { getGlobalApiKey, getGlobalAdminToken } from './api'; +import { API_V1_BASE, buildAuthHeaders } from '@/lib/cpClient'; -const API_BASE = '/api/v1'; +const API_BASE = API_V1_BASE; export interface AccessConstraint { operator: string; // "<=", ">=", "==", "!=", "<", ">" @@ -41,24 +41,9 @@ export interface AccessPolicyRequest { } async function fetchWithAuth(url: string, options: RequestInit = {}): Promise { - const apiKey = getGlobalApiKey(); - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options.headers, - }; - - if (apiKey) { - (headers as Record)['X-Api-Key'] = apiKey; - } - - const adminToken = getGlobalAdminToken(); - if (adminToken) { - (headers as Record)['X-Admin-Token'] = adminToken; - } - const response = await fetch(url, { ...options, - headers, + headers: buildAuthHeaders(options.headers), }); if (!response.ok) { diff --git a/control-plane/web/client/src/services/api.ts b/control-plane/web/client/src/services/api.ts index b9f555c77..49a24242f 100644 --- a/control-plane/web/client/src/services/api.ts +++ b/control-plane/web/client/src/services/api.ts @@ -8,11 +8,15 @@ import type { ConfigSchemaResponse, AgentStatus, AgentStatusUpdate, - MCPHealthResponseModeAware, - MCPServerMetrics, } from '../types/agentfield'; - -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; +import { + API_UI_V1_BASE, + buildAuthHeaders, + cpFetchUiV1Json, + cpFetchV1Json, +} from '@/lib/cpClient'; + +const API_BASE_URL = API_UI_V1_BASE; const STORAGE_KEY = "af_api_key"; // Simple obfuscation for localStorage; not meant as real security. @@ -72,48 +76,11 @@ export function getGlobalAdminToken(): string | null { return globalAdminToken; } -/** - * Enhanced fetch wrapper with retry logic and timeout support - */ -async function fetchWrapper(url: string, options?: RequestInit & { timeout?: number }): Promise { - const { timeout = 10000, ...fetchOptions } = options || {}; - - const headers = new Headers(fetchOptions.headers || {}); - if (globalApiKey) { - headers.set('X-API-Key', globalApiKey); - } - - // Create AbortController for timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(`${API_BASE_URL}${url}`, { - ...fetchOptions, - headers, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); - - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - - return response.json() as Promise; - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Request timeout after ${timeout}ms`); - } - - throw error; - } +async function fetchWrapper( + url: string, + options?: RequestInit & { timeout?: number }, +): Promise { + return cpFetchUiV1Json(url, options); } export async function getNodesSummary(): Promise<{ nodes: AgentNodeSummary[], count: number }> { @@ -188,28 +155,6 @@ export async function getNodeDetailsWithPackageInfo( }); } -/** Mode-aware MCP health summary (optional control-plane surface). */ -export async function getMCPHealthModeAware( - nodeId: string, - mode: AppMode = 'user' -): Promise { - return fetchWrapper( - `/nodes/${nodeId}/mcp/health?mode=${mode}`, - { timeout: 8000 } - ); -} - -/** Per-server or node-level MCP metrics (optional control-plane surface). */ -export async function getMCPServerMetrics( - nodeId: string, - serverId?: string -): Promise { - const path = serverId - ? `/nodes/${nodeId}/mcp/servers/${encodeURIComponent(serverId)}/metrics` - : `/nodes/${nodeId}/mcp/metrics`; - return fetchWrapper(path); -} - // ============================================================================ // Unified Status Management API Functions // ============================================================================ @@ -307,46 +252,11 @@ export async function registerServerlessAgent(invocationUrl: string): Promise<{ skills_count: number; }; }> { - // Use /api/v1 base for this endpoint (not /api/ui/v1) - const API_V1_BASE = '/api/v1'; - const timeout = 15000; - - // Create AbortController for timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const headers = new Headers({ 'Content-Type': 'application/json' }); - if (globalApiKey) { - headers.set('X-API-Key', globalApiKey); - } - - const response = await fetch(`${API_V1_BASE}/nodes/register-serverless`, { - method: 'POST', - headers, - body: JSON.stringify({ invocation_url: invocationUrl }), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - - return response.json(); - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Request timeout after ${timeout}ms`); - } - - throw error; - } + return cpFetchV1Json('/nodes/register-serverless', { + method: 'POST', + body: JSON.stringify({ invocation_url: invocationUrl }), + timeout: 15000, + }); } // ============================================================================ @@ -380,11 +290,7 @@ export type NodeLogProxySettingsResponse = { }; function nodeLogsAuthHeaders(): HeadersInit { - const h: Record = {}; - if (globalApiKey) { - h["X-API-Key"] = globalApiKey; - } - return h; + return buildAuthHeaders(undefined, { json: false }); } /** diff --git a/control-plane/web/client/src/services/configurationApi.ts b/control-plane/web/client/src/services/configurationApi.ts index 031601f30..899eb52d1 100644 --- a/control-plane/web/client/src/services/configurationApi.ts +++ b/control-plane/web/client/src/services/configurationApi.ts @@ -1,7 +1,7 @@ import type { ConfigurationSchema, AgentConfiguration, AgentPackage, AgentLifecycleInfo } from '../types/agentfield'; -import { getGlobalApiKey } from './api'; +import { API_UI_V1_BASE, buildAuthHeaders } from '@/lib/cpClient'; -const API_BASE = '/api/ui/v1'; +const API_BASE = API_UI_V1_BASE; export class ConfigurationApiError extends Error { public status?: number; @@ -13,14 +13,12 @@ export class ConfigurationApiError extends Error { } } -const addAuthHeaders = (options: RequestInit = {}): RequestInit => { - const headers = new Headers(options.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - return { ...options, headers }; -}; +const addAuthHeaders = (options: RequestInit = {}): RequestInit => ({ + ...options, + headers: buildAuthHeaders(options.headers, { + json: options.body != null, + }), +}); const fetchWithTimeout = async (url: string, options: RequestInit & { timeout?: number } = {}) => { const { timeout = 10000, ...fetchOptions } = options; diff --git a/control-plane/web/client/src/services/dashboardService.ts b/control-plane/web/client/src/services/dashboardService.ts index 23cfa00d5..fbd444f66 100644 --- a/control-plane/web/client/src/services/dashboardService.ts +++ b/control-plane/web/client/src/services/dashboardService.ts @@ -1,52 +1,11 @@ import type { DashboardSummary, EnhancedDashboardResponse } from '../types/dashboard'; -import { getGlobalApiKey } from './api'; +import { cpFetchUiV1Json } from '@/lib/cpClient'; -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; - -/** - * Enhanced fetch wrapper with error handling, retry logic, and timeout support - * Following the pattern from api.ts - */ -async function fetchWrapper(url: string, options?: RequestInit & { timeout?: number }): Promise { - const { timeout = 10000, ...fetchOptions } = options || {}; - - const headers = new Headers(fetchOptions.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - - // Create AbortController for timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(`${API_BASE_URL}${url}`, { - ...fetchOptions, - headers, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); - - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - - return response.json() as Promise; - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Request timeout after ${timeout}ms`); - } - - throw error; - } +async function fetchWrapper( + url: string, + options?: RequestInit & { timeout?: number }, +): Promise { + return cpFetchUiV1Json(url, options); } /** @@ -156,26 +115,3 @@ export async function getEnhancedDashboardSummary( ); } -/** - * Trigger metrics for dashboard - */ -export interface TriggerMetrics { - total_triggers: number; - enabled_triggers: number; - orphaned_triggers: number; - events_24h: number; - dispatch_success_24h: number; - dispatch_failed_24h: number; - dispatch_success_rate_24h: number; - dlq_depth: number; -} - -/** - * Get trigger metrics - * GET /api/v1/triggers/metrics - */ -export async function getTriggerMetrics(): Promise { - return fetchWrapper('/triggers/metrics', { - timeout: 8000 - }); -} diff --git a/control-plane/web/client/src/services/didApi.ts b/control-plane/web/client/src/services/didApi.ts index 9964a8c87..081097ccc 100644 --- a/control-plane/web/client/src/services/didApi.ts +++ b/control-plane/web/client/src/services/didApi.ts @@ -5,24 +5,10 @@ import type { DIDFilters, DIDStatusSummary } from '../types/did'; -import { getGlobalApiKey } from './api'; - -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; +import { cpFetchUiV1Json } from '@/lib/cpClient'; async function fetchWrapper(url: string, options?: RequestInit): Promise { - const headers = new Headers(options?.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - const response = await fetch(`${API_BASE_URL}${url}`, { ...options, headers }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - return response.json() as Promise; + return cpFetchUiV1Json(url, options); } // DID Management API Functions diff --git a/control-plane/web/client/src/services/executionTimelineService.ts b/control-plane/web/client/src/services/executionTimelineService.ts index 3b05b9086..eee3f3233 100644 --- a/control-plane/web/client/src/services/executionTimelineService.ts +++ b/control-plane/web/client/src/services/executionTimelineService.ts @@ -1,7 +1,5 @@ +import { cpFetchUiV1Json } from '@/lib/cpClient'; import type { ExecutionTimelineResponse } from '../types/executionTimeline'; -import { getGlobalApiKey } from './api'; - -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; // In-memory cache for timeline data to reduce API calls let timelineCache: { @@ -14,49 +12,11 @@ let timelineCache: { ttl: 300000 // 5 minutes to match backend cache }; -/** - * Enhanced fetch wrapper with error handling and timeout support - */ -async function fetchWrapper(url: string, options?: RequestInit & { timeout?: number }): Promise { - const { timeout = 8000, ...fetchOptions } = options || {}; - - const headers = new Headers(fetchOptions.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - - // Create AbortController for timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(`${API_BASE_URL}${url}`, { - ...fetchOptions, - headers, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); - - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - - return response.json() as Promise; - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Request timeout after ${timeout}ms`); - } - - throw error; - } +async function fetchWrapper( + url: string, + options?: RequestInit & { timeout?: number }, +): Promise { + return cpFetchUiV1Json(url, options); } /** diff --git a/control-plane/web/client/src/services/executionsApi.ts b/control-plane/web/client/src/services/executionsApi.ts index ba3178d87..43f1646ce 100644 --- a/control-plane/web/client/src/services/executionsApi.ts +++ b/control-plane/web/client/src/services/executionsApi.ts @@ -17,29 +17,11 @@ import type { NotesFilters, } from "../types/notes"; import { normalizeExecutionStatus } from "../utils/status"; +import { cpFetchUiV1Json } from "@/lib/cpClient"; import { getGlobalApiKey } from "./api"; -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api/ui/v1"; - async function fetchWrapper(url: string, options?: RequestInit): Promise { - const headers = new Headers(options?.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set("X-API-Key", apiKey); - } - - const response = await fetch(`${API_BASE_URL}${url}`, { ...options, headers }); - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ - message: "Request failed with status " + response.status, - })); - throw new Error( - errorData.message || `HTTP error! status: ${response.status}`, - ); - } - return response.json() as Promise; + return cpFetchUiV1Json(url, options); } export interface CancelExecutionResponse { diff --git a/control-plane/web/client/src/services/identityApi.ts b/control-plane/web/client/src/services/identityApi.ts deleted file mode 100644 index 533ee877e..000000000 --- a/control-plane/web/client/src/services/identityApi.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { getGlobalApiKey } from './api'; - -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api/ui/v1"; - -async function fetchWrapper(url: string, options?: RequestInit): Promise { - const headers = new Headers(options?.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - const response = await fetch(`${API_BASE_URL}${url}`, { ...options, headers }); - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ - message: "Request failed with status " + response.status, - })); - throw new Error( - errorData.message || `HTTP error! status: ${response.status}`, - ); - } - return response.json() as Promise; -} - -// DID Explorer Types -export interface DIDStatsResponse { - total_agents: number; - total_reasoners: number; - total_skills: number; - total_dids: number; -} - -export interface DIDStats extends DIDStatsResponse {} - -export interface DIDSearchResult { - type: "agent" | "reasoner" | "skill"; - did: string; - id: string; // Add missing id property - name: string; - parent_did?: string; - parent_name?: string; - derivation_path: string; - status?: string; - created_at: string; -} - -export interface ComponentDIDInfo { - did: string; - name: string; - component_name: string; - type: "reasoner" | "skill"; - derivation_path: string; - created_at: string; -} - -export interface AgentDIDResponse { - did: string; - did_web?: string; - agent_node_id: string; - status: string; - derivation_path: string; - created_at: string; - reasoner_count: number; - skill_count: number; - reasoners?: ComponentDIDInfo[]; - skills?: ComponentDIDInfo[]; -} - -export interface AgentDIDsResponse extends AgentDIDResponse {} - -// Alias for compatibility -export type AgentDID = AgentDIDResponse; - -export interface AgentDetailsResponse { - agent: AgentDIDResponse; - total_reasoners: number; - reasoners_limit: number; - reasoners_offset: number; - reasoners_has_more: boolean; -} - -// VerifiableCredential interface for Credentials -export interface VerifiableCredential { - vc_id: string; - execution_id: string; - workflow_id: string; - session_id?: string; - issuer_did: string; - target_did: string; - caller_did: string; - reasoner_id: string; - status: string; - created_at: string; - duration_ms?: number; - verified: boolean; - input_hash?: string; - output_hash?: string; - vc_json: any; -} - -// Credentials Types -export interface VCSearchResult { - vc_id: string; - execution_id: string; - workflow_id: string; - workflow_name?: string; - session_id: string; - issuer_did: string; - target_did: string; - caller_did: string; - status: string; - created_at: string; - duration_ms?: number; - reasoner_id?: string; - reasoner_name?: string; - agent_name?: string; - agent_node_id?: string; - verified: boolean; - input_hash?: string; - output_hash?: string; -} - -// DID Explorer API - -export async function getDIDStats(): Promise { - return fetchWrapper("/identity/dids/stats"); -} - -export async function searchDIDs( - query: string, - type: "all" | "agent" | "reasoner" | "skill" = "all", - limit: number = 20, - offset: number = 0 -): Promise<{ - results: DIDSearchResult[]; - total: number; - limit: number; - offset: number; - has_more: boolean; -}> { - const params = new URLSearchParams({ - q: query, - type, - limit: limit.toString(), - offset: offset.toString(), - }); - - return fetchWrapper(`/identity/dids/search?${params.toString()}`); -} - -export async function listAgents( - limit: number = 10, - offset: number = 0 -): Promise<{ - agents: AgentDIDResponse[]; - total: number; - limit: number; - offset: number; - has_more: boolean; -}> { - const params = new URLSearchParams({ - limit: limit.toString(), - offset: offset.toString(), - }); - - return fetchWrapper(`/identity/agents?${params.toString()}`); -} - -export async function getAgentDetails( - agentId: string, - limit: number = 20, - offset: number = 0 -): Promise { - const params = new URLSearchParams({ - limit: limit.toString(), - offset: offset.toString(), - }); - - return fetchWrapper(`/identity/agents/${agentId}/details?${params.toString()}`); -} - -// Credentials API - -export async function searchCredentials(filters: { - workflow_id?: string; - session_id?: string; - status?: string; - issuer_did?: string; - agent_node_id?: string; - execution_id?: string; - caller_did?: string; - target_did?: string; - query?: string; - start_time?: string; - end_time?: string; - limit?: number; - offset?: number; -}): Promise<{ - credentials: VCSearchResult[]; - total: number; - limit: number; - offset: number; - has_more: boolean; -}> { - const params = new URLSearchParams(); - - if (filters.workflow_id) params.append("workflow_id", filters.workflow_id); - if (filters.session_id) params.append("session_id", filters.session_id); - if (filters.status) params.append("status", filters.status); - if (filters.issuer_did) params.append("issuer_did", filters.issuer_did); - if (filters.agent_node_id) params.append("agent_node_id", filters.agent_node_id); - if (filters.execution_id) params.append("execution_id", filters.execution_id); - if (filters.caller_did) params.append("caller_did", filters.caller_did); - if (filters.target_did) params.append("target_did", filters.target_did); - if (filters.query) params.append("query", filters.query); - if (filters.start_time) params.append("start_time", filters.start_time); - if (filters.end_time) params.append("end_time", filters.end_time); - if (filters.limit) params.append("limit", filters.limit.toString()); - if (filters.offset) params.append("offset", filters.offset.toString()); - - return fetchWrapper(`/identity/credentials/search?${params.toString()}`); -} - -// Missing function for getAgentDIDs -export async function getAgentDIDs( - agentId: string, - limit: number = 20, - offset: number = 0 -): Promise<{ - reasoners: ComponentDIDInfo[]; - skills: ComponentDIDInfo[]; - total_reasoners: number; - total_skills: number; -}> { - const params = new URLSearchParams({ - limit: limit.toString(), - offset: offset.toString(), - }); - - return fetchWrapper(`/identity/agents/${agentId}/dids?${params.toString()}`); -} - -// Export default object for compatibility -const identityApi = { - searchCredentials, - getDIDStats, - searchDIDs, - listAgents, - getAgentDetails, - getAgentDIDs, -}; - -export default identityApi; diff --git a/control-plane/web/client/src/services/observabilityWebhookApi.ts b/control-plane/web/client/src/services/observabilityWebhookApi.ts index 53384c721..2ec514dc9 100644 --- a/control-plane/web/client/src/services/observabilityWebhookApi.ts +++ b/control-plane/web/client/src/services/observabilityWebhookApi.ts @@ -1,6 +1,6 @@ -import { getGlobalApiKey } from './api'; +import { API_V1_BASE, buildAuthHeaders } from '@/lib/cpClient'; -const API_BASE = '/api/v1'; +const API_BASE = API_V1_BASE; export class ObservabilityWebhookApiError extends Error { public status?: number; @@ -80,16 +80,10 @@ export interface DeleteWebhookResponse { message: string; } -// Helper functions -const addAuthHeaders = (options: RequestInit = {}): RequestInit => { - const headers = new Headers(options.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - headers.set('Content-Type', 'application/json'); - return { ...options, headers }; -}; +const addAuthHeaders = (options: RequestInit = {}): RequestInit => ({ + ...options, + headers: buildAuthHeaders(options.headers), +}); const fetchWithTimeout = async (url: string, options: RequestInit & { timeout?: number } = {}) => { const { timeout = 10000, ...fetchOptions } = options; diff --git a/control-plane/web/client/src/services/reasonersApi.ts b/control-plane/web/client/src/services/reasonersApi.ts index 1e9223db7..36e5e781b 100644 --- a/control-plane/web/client/src/services/reasonersApi.ts +++ b/control-plane/web/client/src/services/reasonersApi.ts @@ -8,17 +8,12 @@ import type { AsyncExecuteResponse, ExecutionStatusResponse } from '../types/execution'; +import { API_UI_V1_BASE, buildAuthHeaders } from '@/lib/cpClient'; import { getGlobalApiKey } from './api'; -const API_BASE_URL = '/api/ui/v1'; -const withAuthHeaders = (headers?: HeadersInit) => { - const merged = new Headers(headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - merged.set('X-API-Key', apiKey); - } - return merged; -}; +const API_BASE_URL = API_UI_V1_BASE; +const withAuthHeaders = (headers?: HeadersInit) => + buildAuthHeaders(headers, { json: false }); export class ReasonersApiError extends Error { public status?: number; diff --git a/control-plane/web/client/src/services/recentActivityService.ts b/control-plane/web/client/src/services/recentActivityService.ts index d9fc31cdc..7064645da 100644 --- a/control-plane/web/client/src/services/recentActivityService.ts +++ b/control-plane/web/client/src/services/recentActivityService.ts @@ -1,51 +1,11 @@ +import { cpFetchUiV1Json } from '@/lib/cpClient'; import type { RecentActivityResponse } from '../types/recentActivity'; -import { getGlobalApiKey } from './api'; -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; - -/** - * Enhanced fetch wrapper with error handling and timeout support - */ -async function fetchWrapper(url: string, options?: RequestInit & { timeout?: number }): Promise { - const { timeout = 8000, ...fetchOptions } = options || {}; - - const headers = new Headers(fetchOptions.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - - // Create AbortController for timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(`${API_BASE_URL}${url}`, { - ...fetchOptions, - headers, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); - - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - - return response.json() as Promise; - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Request timeout after ${timeout}ms`); - } - - throw error; - } +async function fetchWrapper( + url: string, + options?: RequestInit & { timeout?: number }, +): Promise { + return cpFetchUiV1Json(url, options); } /** diff --git a/control-plane/web/client/src/services/tagApprovalApi.ts b/control-plane/web/client/src/services/tagApprovalApi.ts index 893562f2c..414b696d9 100644 --- a/control-plane/web/client/src/services/tagApprovalApi.ts +++ b/control-plane/web/client/src/services/tagApprovalApi.ts @@ -3,9 +3,9 @@ * API client for tag approval admin endpoints */ -import { getGlobalApiKey, getGlobalAdminToken } from './api'; +import { API_V1_BASE, buildAuthHeaders } from '@/lib/cpClient'; -const API_BASE = '/api/v1'; +const API_BASE = API_V1_BASE; export interface PendingAgentResponse { agent_id: string; @@ -27,24 +27,9 @@ export interface TagRejectionRequest { } async function fetchWithAuth(url: string, options: RequestInit = {}): Promise { - const apiKey = getGlobalApiKey(); - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options.headers, - }; - - if (apiKey) { - (headers as Record)['X-Api-Key'] = apiKey; - } - - const adminToken = getGlobalAdminToken(); - if (adminToken) { - (headers as Record)['X-Admin-Token'] = adminToken; - } - const response = await fetch(url, { ...options, - headers, + headers: buildAuthHeaders(options.headers), }); if (!response.ok) { diff --git a/control-plane/web/client/src/services/triggersApi.ts b/control-plane/web/client/src/services/triggersApi.ts new file mode 100644 index 000000000..50684b099 --- /dev/null +++ b/control-plane/web/client/src/services/triggersApi.ts @@ -0,0 +1,149 @@ +import { + API_V1_BASE, + cpFetchV1Json, + getControlPlaneOrigin, +} from '@/lib/cpClient'; + +export interface SourceCatalogEntry { + name: string; + kind: 'http' | 'loop' | string; + secret_required: boolean; + config_schema: Record; +} + +export interface Trigger { + id: string; + source_name: string; + config: Record | string | null; + secret_env_var: string; + target_node_id: string; + target_reasoner: string; + event_types?: string[] | null; + managed_by: 'code' | 'ui'; + enabled: boolean; + created_at: string; + updated_at: string; + event_count_24h?: number; + dispatch_success_24h?: number; + dispatch_failed_24h?: number; + last_event_at?: string | null; + dispatch_buckets_24h?: number[]; +} + +export interface TriggerMetrics { + total_triggers: number; + enabled_triggers: number; + orphaned_triggers: number; + events_24h: number; + dispatch_success_24h: number; + dispatch_failed_24h: number; + dispatch_success_rate_24h: number; + dlq_depth: number; +} + +export interface InboundEvent { + id: string; + trigger_id?: string; + received_at: string; + status: string; + raw_payload?: unknown; + [key: string]: unknown; +} + +export interface CreateTriggerRequest { + source_name: string; + target_node_id: string; + target_reasoner: string; + event_types?: string[]; + secret_env_var?: string; + config?: Record; + enabled?: boolean; +} + +/** Public ingest URL for a trigger (no auth on inbound webhooks). */ +export function getTriggerIngestUrl(triggerId: string): string { + return `${getControlPlaneOrigin()}/sources/${triggerId}`; +} + +export async function listTriggerSources(): Promise { + const res = await cpFetchV1Json<{ sources: SourceCatalogEntry[] }>( + '/sources', + { timeout: 8000 }, + ); + return res.sources ?? []; +} + +export async function listTriggers(): Promise { + const res = await cpFetchV1Json<{ triggers: Trigger[] }>('/triggers', { + timeout: 8000, + }); + return res.triggers ?? []; +} + +export async function listTriggersForNode(nodeId: string): Promise { + const res = await cpFetchV1Json<{ triggers: Trigger[] }>( + `/triggers?target_node_id=${encodeURIComponent(nodeId)}`, + { timeout: 8000 }, + ); + return res.triggers ?? []; +} + +export async function createTrigger( + body: CreateTriggerRequest, +): Promise { + return cpFetchV1Json('/triggers', { + method: 'POST', + body: JSON.stringify(body), + timeout: 15000, + }); +} + +export async function updateTrigger( + triggerId: string, + patch: Partial, +): Promise { + return cpFetchV1Json(`/triggers/${encodeURIComponent(triggerId)}`, { + method: 'PUT', + body: JSON.stringify(patch), + timeout: 15000, + }); +} + +export async function deleteTrigger(triggerId: string): Promise { + await cpFetchV1Json(`/triggers/${encodeURIComponent(triggerId)}`, { + method: 'DELETE', + timeout: 15000, + }); +} + +export async function listTriggerEvents( + triggerId: string, +): Promise { + const res = await cpFetchV1Json<{ events: InboundEvent[] }>( + `/triggers/${encodeURIComponent(triggerId)}/events`, + { timeout: 8000 }, + ); + return res.events ?? []; +} + +export async function replayTriggerEvent( + triggerId: string, + eventId: string, +): Promise { + await cpFetchV1Json( + `/triggers/${encodeURIComponent(triggerId)}/events/${encodeURIComponent(eventId)}/replay`, + { method: 'POST', timeout: 15000 }, + ); +} + +/** GET /api/v1/triggers/metrics */ +export async function getTriggerMetrics(): Promise { + return cpFetchV1Json('/triggers/metrics', { + timeout: 8000, + }); +} + +/** Absolute URL for v1 paths (e.g. TriggerSheet serverUrl prop). */ +export function getTriggersApiBaseUrl(): string { + return `${getControlPlaneOrigin()}${API_V1_BASE}`; +} diff --git a/control-plane/web/client/src/services/vcApi.ts b/control-plane/web/client/src/services/vcApi.ts index c68552622..457cabd4e 100644 --- a/control-plane/web/client/src/services/vcApi.ts +++ b/control-plane/web/client/src/services/vcApi.ts @@ -13,24 +13,10 @@ import type { ProvenanceVerificationResponse } from '../types/did'; import { normalizeExecutionStatus, isSuccessStatus, isFailureStatus } from '../utils/status'; -import { getGlobalApiKey } from './api'; - -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; +import { cpDownloadBlob, cpFetchUiV1Json } from '@/lib/cpClient'; async function fetchWrapper(url: string, options?: RequestInit): Promise { - const headers = new Headers(options?.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - const response = await fetch(`${API_BASE_URL}${url}`, { ...options, headers }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - return response.json() as Promise; + return cpFetchUiV1Json(url, options); } // VC Management API Functions @@ -730,22 +716,10 @@ export async function getDIDResolutionBundle(did: string): Promise<{ */ export async function downloadDIDResolutionBundle(did: string): Promise { try { - - const response = await fetch(`${API_BASE_URL}/did/${encodeURIComponent(did)}/resolution-bundle/download`); - - if (!response.ok) { - throw new Error(`Failed to download DID resolution bundle: ${response.status}`); - } - - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `did-resolution-bundle-${did.replace(/[^a-zA-Z0-9]/g, '_')}.json`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + await cpDownloadBlob( + `/did/${encodeURIComponent(did)}/resolution-bundle/download`, + `did-resolution-bundle-${did.replace(/[^a-zA-Z0-9]/g, '_')}.json`, + ); } catch (error) { console.error('Failed to download DID resolution bundle:', error); throw new Error('Failed to download DID resolution bundle'); diff --git a/control-plane/web/client/src/services/workflowsApi.ts b/control-plane/web/client/src/services/workflowsApi.ts index 04025d975..500cbe2a0 100644 --- a/control-plane/web/client/src/services/workflowsApi.ts +++ b/control-plane/web/client/src/services/workflowsApi.ts @@ -6,25 +6,16 @@ import type { WorkflowDAGLightweightResponse, } from '../types/workflows'; import { normalizeExecutionStatus } from '../utils/status'; -import { getGlobalApiKey } from './api'; +import { API_UI_V2_BASE, cpFetchJson } from '@/lib/cpClient'; -const API_V1_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; -const API_V2_BASE_URL = import.meta.env.VITE_API_V2_BASE_URL || '/api/ui/v2'; +const API_V2_BASE_URL = API_UI_V2_BASE; -async function fetchWrapper(url: string, options?: RequestInit, baseUrl: string = API_V1_BASE_URL): Promise { - const headers = new Headers(options?.headers || {}); - const apiKey = getGlobalApiKey(); - if (apiKey) { - headers.set('X-API-Key', apiKey); - } - const response = await fetch(`${baseUrl}${url}`, { ...options, headers }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); - } - return response.json() as Promise; +async function fetchWrapper( + url: string, + options?: RequestInit, + baseUrl?: string, +): Promise { + return cpFetchJson(url, options, baseUrl); } function buildQueryString(params: Record): string { diff --git a/control-plane/web/client/src/test/lib/libUtils.test.ts b/control-plane/web/client/src/test/lib/libUtils.test.ts index a3233cbd9..edfd36d74 100644 --- a/control-plane/web/client/src/test/lib/libUtils.test.ts +++ b/control-plane/web/client/src/test/lib/libUtils.test.ts @@ -97,14 +97,17 @@ describe("lib helpers", () => { globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 }); await expect(areGovernanceAdminRoutesAvailable()).resolves.toBe(true); - expect(globalThis.fetch).toHaveBeenCalledWith("/api/v1/admin/policies", { - method: "GET", - headers: { - "Content-Type": "application/json", - "X-Api-Key": "api-key", - "X-Admin-Token": "admin-token", - }, - }); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/v1/admin/policies", + expect.objectContaining({ + method: "GET", + headers: expect.any(Headers), + }), + ); + const init = vi.mocked(globalThis.fetch).mock.calls[0][1] as RequestInit; + const headers = init.headers as Headers; + expect(headers.get("X-API-Key")).toBe("api-key"); + expect(headers.get("X-Admin-Token")).toBe("admin-token"); }); it("treats a 404 governance probe as unavailable", async () => { diff --git a/control-plane/web/client/src/test/pages/NewDashboardPage.test.tsx b/control-plane/web/client/src/test/pages/NewDashboardPage.test.tsx index b0f1ea4f8..8039d9c15 100644 --- a/control-plane/web/client/src/test/pages/NewDashboardPage.test.tsx +++ b/control-plane/web/client/src/test/pages/NewDashboardPage.test.tsx @@ -68,6 +68,9 @@ vi.mock("@/hooks/queries", () => ({ vi.mock("@/services/dashboardService", () => ({ getDashboardSummary: vi.fn(), +})); + +vi.mock("@/services/triggersApi", () => ({ getTriggerMetrics: vi.fn(() => Promise.resolve({ total_triggers: 0, enabled_triggers: 0, diff --git a/control-plane/web/client/src/test/pages/NewSettingsPage.restored.test.tsx b/control-plane/web/client/src/test/pages/NewSettingsPage.restored.test.tsx index f690f4a23..a205ee3fa 100644 --- a/control-plane/web/client/src/test/pages/NewSettingsPage.restored.test.tsx +++ b/control-plane/web/client/src/test/pages/NewSettingsPage.restored.test.tsx @@ -299,12 +299,10 @@ describe("NewSettingsPage restored coverage", () => { expect(screen.getByRole("tab", { name: "Observability" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Agent logs" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Identity" })).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "About" })).toBeInTheDocument(); expect(await screen.findByDisplayValue("https://hooks.example.test/events")).toBeInTheDocument(); expect(await screen.findByDisplayValue("did:web:agentfield.example.test")).toBeInTheDocument(); - expect(screen.getByText("About AgentField")).toBeInTheDocument(); expect(screen.getByText("Node log proxy")).toBeInTheDocument(); expect(screen.getByText("Execution Events")).toBeInTheDocument(); expect(screen.getByText("Reasoner Events")).toBeInTheDocument(); diff --git a/control-plane/web/client/src/test/services/identityApi.test.ts b/control-plane/web/client/src/test/services/identityApi.test.ts deleted file mode 100644 index 127cddd17..000000000 --- a/control-plane/web/client/src/test/services/identityApi.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const apiState = vi.hoisted(() => ({ - getGlobalApiKey: vi.fn(), -})); - -vi.mock("@/services/api", () => apiState); - -const originalFetch = globalThis.fetch; - -function buildJsonResponse(body: unknown, init?: ResponseInit): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "Content-Type": "application/json" }, - ...init, - }); -} - -describe("identityApi", () => { - beforeEach(() => { - vi.resetModules(); - apiState.getGlobalApiKey.mockReset(); - apiState.getGlobalApiKey.mockReturnValue("identity-key"); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it("gets DID stats and applies the API key header", async () => { - const payload = { - total_agents: 5, - total_reasoners: 10, - total_skills: 4, - total_dids: 19, - }; - globalThis.fetch = vi.fn().mockResolvedValue(buildJsonResponse(payload)); - - const service = await import("@/services/identityApi"); - await expect(service.getDIDStats()).resolves.toEqual(payload); - - const [url, options] = vi.mocked(globalThis.fetch).mock.calls[0]; - expect(url).toBe("/api/ui/v1/identity/dids/stats"); - expect((options?.headers as Headers).get("X-API-Key")).toBe("identity-key"); - }); - - it("builds search and listing query strings with defaults and custom values", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValueOnce( - buildJsonResponse({ - results: [], - total: 0, - limit: 20, - offset: 0, - has_more: false, - }) - ) - .mockResolvedValueOnce( - buildJsonResponse({ - agents: [], - total: 0, - limit: 15, - offset: 30, - has_more: false, - }) - ) - .mockResolvedValueOnce( - buildJsonResponse({ - did: "did:agent:123", - did_web: "did:web:agentfield.example", - agent_node_id: "agent-123", - status: "active", - derivation_path: "/agents/agent-123", - created_at: "2026-04-01T00:00:00Z", - reasoner_count: 2, - skill_count: 3, - }) - ) - .mockResolvedValueOnce( - buildJsonResponse({ - reasoners: [], - skills: [], - total_reasoners: 2, - total_skills: 3, - }) - ); - - const service = await import("@/services/identityApi"); - - await expect(service.searchDIDs("agent search")).resolves.toMatchObject({ - total: 0, - limit: 20, - offset: 0, - }); - await expect(service.listAgents(15, 30)).resolves.toMatchObject({ - limit: 15, - offset: 30, - }); - await expect(service.getAgentDetails("agent-123", 7, 9)).resolves.toMatchObject({ - agent_node_id: "agent-123", - }); - await expect(service.getAgentDIDs("agent-123", 8, 11)).resolves.toMatchObject({ - total_reasoners: 2, - total_skills: 3, - }); - - const calls = vi.mocked(globalThis.fetch).mock.calls; - expect(calls[0][0]).toBe( - "/api/ui/v1/identity/dids/search?q=agent+search&type=all&limit=20&offset=0" - ); - expect(calls[1][0]).toBe("/api/ui/v1/identity/agents?limit=15&offset=30"); - expect(calls[2][0]).toBe("/api/ui/v1/identity/agents/agent-123/details?limit=7&offset=9"); - expect(calls[3][0]).toBe("/api/ui/v1/identity/agents/agent-123/dids?limit=8&offset=11"); - }); - - it("serializes all credential search filters and skips falsy optional values", async () => { - const payload = { - credentials: [], - total: 0, - limit: 99, - offset: 3, - has_more: false, - }; - globalThis.fetch = vi - .fn() - .mockResolvedValueOnce(buildJsonResponse(payload)) - .mockResolvedValueOnce(buildJsonResponse(payload)); - - const { searchCredentials } = await import("@/services/identityApi"); - - await expect( - searchCredentials({ - workflow_id: "wf-1", - session_id: "session-1", - status: "verified", - issuer_did: "did:issuer:1", - agent_node_id: "agent-1", - execution_id: "exec-1", - caller_did: "did:caller:1", - target_did: "did:target:1", - query: "proof", - start_time: "2026-04-01T00:00:00Z", - end_time: "2026-04-02T00:00:00Z", - limit: 99, - offset: 3, - }) - ).resolves.toEqual(payload); - - await expect(searchCredentials({ limit: 0, offset: 0, query: "" })).resolves.toEqual(payload); - - const [firstUrl] = vi.mocked(globalThis.fetch).mock.calls[0]; - expect(firstUrl).toBe( - "/api/ui/v1/identity/credentials/search?workflow_id=wf-1&session_id=session-1&status=verified&issuer_did=did%3Aissuer%3A1&agent_node_id=agent-1&execution_id=exec-1&caller_did=did%3Acaller%3A1&target_did=did%3Atarget%3A1&query=proof&start_time=2026-04-01T00%3A00%3A00Z&end_time=2026-04-02T00%3A00%3A00Z&limit=99&offset=3" - ); - - const [secondUrl] = vi.mocked(globalThis.fetch).mock.calls[1]; - expect(secondUrl).toBe("/api/ui/v1/identity/credentials/search?"); - }); - - it("uses response JSON messages and fallback messages for API failures", async () => { - globalThis.fetch = vi - .fn() - .mockResolvedValueOnce( - buildJsonResponse({ message: "DID lookup failed" }, { status: 404, statusText: "Not Found" }) - ) - .mockResolvedValueOnce({ - ok: false, - status: 502, - json: vi.fn().mockRejectedValue(new Error("bad json")), - }); - - const service = await import("@/services/identityApi"); - - await expect(service.getDIDStats()).rejects.toThrow("DID lookup failed"); - await expect(service.listAgents()).rejects.toThrow("Request failed with status 502"); - }); - - it("omits the API key header when no key is configured", async () => { - apiState.getGlobalApiKey.mockReturnValue(null); - globalThis.fetch = vi.fn().mockResolvedValue( - buildJsonResponse({ - results: [], - total: 0, - limit: 20, - offset: 0, - has_more: false, - }) - ); - - const { searchDIDs } = await import("@/services/identityApi"); - await searchDIDs("plain"); - - const [, options] = vi.mocked(globalThis.fetch).mock.calls[0]; - expect((options?.headers as Headers).get("X-API-Key")).toBeNull(); - }); - - it("exports the compatibility default object", async () => { - const service = await import("@/services/identityApi"); - - expect(service.default.getDIDStats).toBe(service.getDIDStats); - expect(service.default.searchDIDs).toBe(service.searchDIDs); - expect(service.default.listAgents).toBe(service.listAgents); - expect(service.default.getAgentDetails).toBe(service.getAgentDetails); - expect(service.default.getAgentDIDs).toBe(service.getAgentDIDs); - expect(service.default.searchCredentials).toBe(service.searchCredentials); - }); -}); diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index c1a2712c2..424ef278a 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -1,123 +1,93 @@ # Environment Variables -This repo supports running AgentField in multiple modes (local binary, Docker, Kubernetes). Most configuration is loaded via a YAML config file and can be overridden via environment variables. +AgentField loads configuration from `config/agentfield.yaml` (or the path in `AGENTFIELD_CONFIG_FILE`) and allows overrides via environment variables. -AgentField uses Viper with the prefix `AGENTFIELD` and maps nested config keys using `_` (for example `storage.mode` β†’ `AGENTFIELD_STORAGE_MODE`). +Viper uses the prefix `AGENTFIELD` and maps nested config keys with `_` (for example `storage.mode` β†’ `AGENTFIELD_STORAGE_MODE`). + +See [`control-plane/.env.example`](../control-plane/.env.example) for a copy-paste starter file. ## Control Plane (Server) ### Core -- `AGENTFIELD_PORT` (optional): HTTP port for the control plane (default: `8080`). -- `AGENTFIELD_CONFIG_FILE` (optional): Path to `agentfield.yaml` (in containers this is typically `/etc/agentfield/config/agentfield.yaml`). -- `AGENTFIELD_HOME` (recommended in containers): Base directory where AgentField stores local state (SQLite DB, Bolt DB, keys, logs). In Kubernetes, mount a PVC and set `AGENTFIELD_HOME=/data`. +- `AGENTFIELD_PORT` (default: `8080`): HTTP port for the control plane. +- `AGENTFIELD_MODE` (default: `local`): Runtime mode (`local` for dev/single-node). +- `AGENTFIELD_CONFIG_FILE` (optional): Path to `agentfield.yaml`. ### Storage -AgentField supports: -- **local** (SQLite + BoltDB, stored under `AGENTFIELD_HOME`) -- **postgres** (PostgreSQL + pgvector) +Supported modes: **`local`** (SQLite + BoltDB) and **`postgres`** (PostgreSQL). -Common: -- `AGENTFIELD_STORAGE_MODE`: `local` (default) or `postgres`. +- `AGENTFIELD_STORAGE_MODE` (default: `local`): `local` or `postgres`. -Local storage (usually not needed if `AGENTFIELD_HOME` is set): -- `AGENTFIELD_STORAGE_LOCAL_DATABASE_PATH`: SQLite path. -- `AGENTFIELD_STORAGE_LOCAL_KV_STORE_PATH`: BoltDB path. +Local storage: -PostgreSQL storage: -- `AGENTFIELD_POSTGRES_URL` (preferred) or `AGENTFIELD_STORAGE_POSTGRES_URL`: PostgreSQL DSN/URL (examples below). -- Alternatively, individual fields: - - `AGENTFIELD_STORAGE_POSTGRES_HOST` - - `AGENTFIELD_STORAGE_POSTGRES_PORT` - - `AGENTFIELD_STORAGE_POSTGRES_DATABASE` - - `AGENTFIELD_STORAGE_POSTGRES_USER` - - `AGENTFIELD_STORAGE_POSTGRES_PASSWORD` - - `AGENTFIELD_STORAGE_POSTGRES_SSLMODE` +- `AGENTFIELD_STORAGE_LOCAL_DATABASE_PATH`: SQLite database path. +- `AGENTFIELD_STORAGE_LOCAL_KV_STORE_PATH`: BoltDB key-value store path. -Example DSNs: -- `postgres://agentfield:agentfield@postgres:5432/agentfield?sslmode=disable` -- `postgresql://agentfield:agentfield@postgres:5432/agentfield?sslmode=disable` +PostgreSQL storage (when `AGENTFIELD_STORAGE_MODE=postgres`): -### API Authentication (optional) +- `AGENTFIELD_STORAGE_POSTGRES_URL`: PostgreSQL DSN/URL. +- `AGENTFIELD_STORAGE_POSTGRES_MAX_CONNECTIONS` (optional) +- `AGENTFIELD_STORAGE_POSTGRES_MAX_IDLE_CONNECTIONS` (optional) +- `AGENTFIELD_STORAGE_POSTGRES_CONNECTION_TIMEOUT` (optional) +- `AGENTFIELD_STORAGE_POSTGRES_QUERY_TIMEOUT` (optional) +- `AGENTFIELD_STORAGE_POSTGRES_ENABLE_AUTO_MIGRATION` (optional) -If set, the control plane requires an API key for most endpoints. +Example DSN: -- `AGENTFIELD_API_KEY` or `AGENTFIELD_API_AUTH_API_KEY`: API key checked by the control plane. +``` +postgresql://agentfield:agentfield@localhost:5432/agentfield?sslmode=disable +``` ### UI - `AGENTFIELD_UI_ENABLED` (default: `true`) -- `AGENTFIELD_UI_MODE` (default: `embedded`) - -### Anonymous Telemetry +- `AGENTFIELD_UI_MODE` (default: `embedded`): `embedded` or `development` +- `AGENTFIELD_UI_SOURCE_PATH`: Path to the UI source tree (dev builds). +- `AGENTFIELD_UI_DIST_PATH`: Path to the built UI `dist/` directory. +- `AGENTFIELD_UI_DEV_PORT` (default: `5173`): Vite dev server port when `UI_MODE=development`. -Anonymous usage telemetry is enabled by default to help us improve AgentField. It records coarse product signals such as startup, agent registration, SDK language, runtime type, storage mode, and execution status buckets. +### API / CORS -It does not collect prompts, inputs, outputs, logs, secrets, API keys, raw IP addresses, hostnames, user IDs, DIDs, or raw error text. +When set, the control plane requires an API key for most `/api/v1` endpoints: -- `AGENTFIELD_TELEMETRY_ENABLED` (default: `true`): Set to `false` to disable anonymous usage telemetry. -- `AGENTFIELD_TELEMETRY_ENDPOINT` (default: `https://agentfield.ai/api/oss/telemetry`): Hosted anonymous telemetry endpoint. -- `AGENTFIELD_TELEMETRY_INSTALL_ID` (optional): Stable externally managed anonymous install ID. The control plane hashes it before sending. -- `AGENTFIELD_TELEMETRY_INSTALL_ID_PATH` (optional): Path for the persisted local install ID. -- `AGENTFIELD_TELEMETRY_TIMEOUT` (default: `800ms`): Per-event send timeout. Failures are ignored. +- `AGENTFIELD_API_KEY` or `AGENTFIELD_API_AUTH_API_KEY` -### CORS (HTTP API) +CORS settings (comma-separated lists where noted): -These map to `api.cors.*` in config. When set via env, use comma-separated values. - -- `AGENTFIELD_API_CORS_ALLOWED_ORIGINS` (comma-separated) -- `AGENTFIELD_API_CORS_ALLOWED_METHODS` (comma-separated) -- `AGENTFIELD_API_CORS_ALLOWED_HEADERS` (comma-separated) -- `AGENTFIELD_API_CORS_EXPOSED_HEADERS` (comma-separated) +- `AGENTFIELD_API_CORS_ALLOWED_ORIGINS` +- `AGENTFIELD_API_CORS_ALLOWED_METHODS` +- `AGENTFIELD_API_CORS_ALLOWED_HEADERS` +- `AGENTFIELD_API_CORS_EXPOSED_HEADERS` - `AGENTFIELD_API_CORS_ALLOW_CREDENTIALS` (`true`/`false`) -### Authorization (VC-Based Permissions) - -When enabled, the control plane issues DID identities to agents and enforces tag-based access policies on agent-to-agent calls. - -- `AGENTFIELD_AUTHORIZATION_ENABLED` (default: `false`): Enable VC-based authorization. -- `AGENTFIELD_AUTHORIZATION_MASTER_SEED` (required when enabled): Master seed for deriving Ed25519 keypairs for agent DIDs. Keep this secret and consistent across restarts β€” changing it invalidates all existing DID signatures. -- `AGENTFIELD_AUTHORIZATION_TAG_APPROVAL_MODE` (default: `auto`): `auto` (tags approved immediately) or `admin` (tags require admin approval before the agent becomes ready). -- `AGENTFIELD_AUTHORIZATION_DEFAULT_DENY` (default: `false`): When `true`, the tag policy middleware returns HTTP 403 for any request where no access policy matches the `(caller_tags, target_tags, function)` tuple. Default is `false`, preserving the existing behavior of allowing unmatched requests. The unmatched tuple is logged at `DEBUG` in both modes for diagnosis. Equivalent YAML: `features.did.authorization.default_deny`. - -### Connector (External Management API) - -The connector API provides token-authenticated management endpoints for external systems (CI/CD, orchestration platforms, dashboards). - -- `AGENTFIELD_CONNECTOR_TOKEN` (optional): Bearer token required for all `/connector/*` endpoints. -- `AGENTFIELD_CONNECTOR_CAPABILITIES` (optional, default: all): Comma-separated list of granted capabilities. Available capabilities: `reasoners:read`, `reasoners:write`, `versions:read`, `versions:write`, `restart`. - -Example: -``` -AGENTFIELD_CONNECTOR_TOKEN=my-secret-token -AGENTFIELD_CONNECTOR_CAPABILITIES=reasoners:read,versions:read,versions:write,restart -``` +### VC Authorization (optional) -## Agent Nodes +When DID-based authorization is enabled in config: -Agent nodes run as separate processes/pods and register with the control plane. The most important Kubernetes-specific concept is: +- `AGENTFIELD_AUTHORIZATION_ADMIN_TOKEN`: Admin token for tag approval and policy management endpoints. +- `AGENTFIELD_AUTHORIZATION_INTERNAL_TOKEN`: Token forwarded to agents; agents with `RequireOriginAuth=true` validate this on inbound calls. +- `AGENTFIELD_AUTHORIZATION_DEFAULT_DENY` (default: `false`): When `true`, unmatched access-policy tuples return HTTP 403 instead of being allowed. -- The **control plane must be able to reach the agent** at the URL the agent registers (its callback/public URL). -- In Kubernetes, this should usually be a `Service` DNS name (for example `http://my-agent.default.svc.cluster.local:8001`). +### Node log proxy (optional) -The same concept applies to **Docker**: +Overrides for `GET /api/ui/v1/nodes/:nodeId/logs` (otherwise YAML / DB overlay): -- If the control plane runs in a container and the agent runs on your host, set the agent’s callback/public URL to `host.docker.internal` (or the Docker host gateway on Linux). -- If both run in the same Docker network/Compose project, set the callback/public URL to the agent service name (for example `http://demo-go-agent:8001`). +- `AGENTFIELD_NODE_LOG_PROXY_CONNECT_TIMEOUT` +- `AGENTFIELD_NODE_LOG_PROXY_STREAM_IDLE_TIMEOUT` +- `AGENTFIELD_NODE_LOG_PROXY_MAX_DURATION` +- `AGENTFIELD_NODE_LOG_MAX_TAIL_LINES` -### Go SDK agents (example: `examples/go_agent_nodes`) +### Python agent process logs (optional) -- `AGENTFIELD_URL` (optional): Control plane base URL (example: `http://agentfield:8080`). -- `AGENTFIELD_TOKEN` (optional): Bearer token (use this if you enable `AGENTFIELD_API_KEY` on the control plane). -- `AGENT_NODE_ID` (optional): Node id (default varies by example). -- `AGENT_LISTEN_ADDR` (optional): Listen address (default: `:8001`). -- `AGENT_PUBLIC_URL` (recommended in Docker/Kubernetes): Public URL the control plane will call back to (example: `http://my-agent:8001`). +See [`docs/api/AGENT_NODE_LOGS.md`](api/AGENT_NODE_LOGS.md): -### Python SDK agents +- `AGENTFIELD_LOGS_ENABLED` +- `AGENTFIELD_LOG_BUFFER_BYTES` +- `AGENTFIELD_LOG_MAX_LINE_BYTES` -- `AGENTFIELD_URL` (recommended): Control plane base URL. -- `AGENT_NODE_ID` (optional): Node id. -- `AGENT_CALLBACK_URL` (recommended in Docker/Kubernetes): URL the control plane will call back to (examples: `http://my-agent:8001`, or for host-run agents with Dockerized control plane: `http://host.docker.internal:8001`). +### Development / debug -Many Python examples also require model provider credentials (for example `OPENAI_API_KEY`), depending on the `AIConfig` you choose. +- `GIN_MODE`: `debug` or `release` +- `LOG_LEVEL`: `debug`, `info`, `warn`, or `error`