feat: Skill Hub hardening with session usage tracking and egress governance (#163)
* feat(skill-hub): add Skill Hub catalog, publish flow, and lite materialize pipeline Introduce Skill Hub for browsing, importing, publishing, and installing skills, with lite instance package materialization and runtime sync support. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): remove token-governance hooks from Skill Hub PR Strip validateManagedRuntimeEnvironmentOverrides, network lock policy sync, and egress proxy audit wiring that belong to the upcoming token-usage work, so the Skill Hub branch compiles independently. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): update migration number in materialize docs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): make RuntimeAgentClient test stub and hub tests compile-safe Add ResyncInstanceSkills to the runtime pool handler fake client, and harden skill hub payload helpers/tests against nil storage/instance repos so go test passes. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add Skill Hub hardening with session usage tracking and egress governance Unify Skill Hub runtime sync improvements with session-token observability, egress network policy, and admin/instance usage reporting for reopenable PR. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): repair CI tests and nested skill install * fix(ci): restore release deployment configuration --------- Co-authored-by: heshengran <heshengran@ieisystem.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -166,6 +166,12 @@ jobs:
|
||||
text,
|
||||
)
|
||||
|
||||
text = re.sub(
|
||||
r"(\n\s+- name: workspaces\s+)persistentVolumeClaim:\s+claimName: clawmanager-workspaces",
|
||||
r"\1emptyDir: {}",
|
||||
text,
|
||||
)
|
||||
|
||||
path.write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*.dylib
|
||||
/backend/bin/
|
||||
/backend/tmp/
|
||||
/runtimeagent/
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -61,6 +62,14 @@ Thumbs.db
|
||||
/fix-install-plan.md
|
||||
/docs/deployment-storage-remediation-plan.zh-CN.md
|
||||
|
||||
# Local API / debug artifacts (do not commit)
|
||||
/_*.json
|
||||
/_*.txt
|
||||
/_test_*.js
|
||||
/_pr*.md
|
||||
.clawmanager-last-image
|
||||
/pr138.patch
|
||||
|
||||
# Team runtime config snapshots
|
||||
/team-*.json
|
||||
/agency-agents-main/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -104,7 +104,7 @@ func main() {
|
||||
log.Fatalf("Failed to initialize object storage: %v", err)
|
||||
}
|
||||
skillScannerClient := services.NewSkillScannerClient(cfg.SkillScanner)
|
||||
aiObservabilityService := services.NewAIObservabilityService(modelInvocationRepo, auditEventRepo, costRecordRepo, riskHitRepo, chatMessageRepo, llmModelRepo, instanceRepo, userRepo)
|
||||
aiObservabilityService := services.NewAIObservabilityService(modelInvocationRepo, auditEventRepo, costRecordRepo, riskHitRepo, chatMessageRepo, chatSessionRepo, llmModelRepo, instanceRepo, userRepo, instanceRuntimeStatusRepo)
|
||||
clusterResourceService := services.NewClusterResourceService(instanceRepo)
|
||||
services.SetRuntimeImageSettingsProvider(systemImageSettingService)
|
||||
services.SetOpenClawTransferRuntimeRepositories(instanceRepo, bindingRepo, runtimePodRepo)
|
||||
@@ -140,7 +140,19 @@ func main() {
|
||||
runtimeEvents := services.NewRuntimeEventService(platformRedis)
|
||||
workspaceFileService := services.NewWorkspaceFileService(workspaceFileAuditRepo)
|
||||
runtimeWorkspaceFileService := services.NewRuntimeWorkspaceFileService(workspaceFileAuditRepo)
|
||||
skillService := services.NewSkillService(skillRepo, instanceRepo, instanceCommandService, objectStorageService, skillScannerClient)
|
||||
skillService := services.NewSkillService(skillRepo, instanceRepo, userRepo, instanceCommandService, instanceCommandRepo, objectStorageService, skillScannerClient)
|
||||
materializeJobRepo := repository.NewSkillPackageMaterializeJobRepository(database)
|
||||
materializeService := services.NewSkillPackageMaterializeService(materializeJobRepo, skillRepo, services.SkillServiceAsMaterializer(skillService))
|
||||
services.ConfigureSkillPackageMaterialize(skillService, materializeService)
|
||||
materializeWorker := services.NewSkillPackageMaterializeWorker(
|
||||
materializeService,
|
||||
time.Duration(cfg.SkillMaterialize.TickMS)*time.Millisecond,
|
||||
cfg.SkillMaterialize.BatchSize,
|
||||
cfg.SkillMaterialize.Concurrency,
|
||||
cfg.SkillMaterialize.PerInstanceConcurrency,
|
||||
cfg.SkillMaterialize.Enabled,
|
||||
)
|
||||
services.ConfigureSkillRuntimeSync(skillService, bindingRepo, runtimePodRepo, runtimeAgentClient)
|
||||
securityScanService := services.NewSecurityScanService(securityScanRepo, skillRepo, objectStorageService, skillScannerClient)
|
||||
externalAccessService := services.NewInstanceExternalAccessService(instanceExternalAccessRepo)
|
||||
aiGatewayService := aigateway.NewService(llmModelRepo, modelInvocationService, auditEventService, costRecordService, riskDetectionService, riskHitService, chatSessionService, chatMessageService)
|
||||
@@ -157,6 +169,7 @@ func main() {
|
||||
openClawConfigService,
|
||||
skillService,
|
||||
externalAccessService,
|
||||
aiObservabilityService,
|
||||
services.WithInstanceProxyRuntimeRepositories(instanceRepo, runtimePodRepo, bindingRepo),
|
||||
)
|
||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(systemImageSettingService)
|
||||
@@ -165,15 +178,16 @@ func main() {
|
||||
aiObservabilityHandler := handlers.NewAIObservabilityHandler(aiObservabilityService)
|
||||
riskRuleHandler := handlers.NewRiskRuleHandler(riskRuleService)
|
||||
clusterResourceHandler := handlers.NewClusterResourceHandler(clusterResourceService)
|
||||
egressProxyHandler := handlers.NewEgressProxyHandler()
|
||||
egressProxyHandler := handlers.NewEgressProxyHandler(auditEventService)
|
||||
openClawConfigHandler := handlers.NewOpenClawConfigHandler(openClawConfigService)
|
||||
skillHandler := handlers.NewSkillHandler(skillService, instanceService)
|
||||
skillHubHandler := handlers.NewSkillHubHandler(skillService, instanceService)
|
||||
securityHandler := handlers.NewSecurityHandler(securityScanService)
|
||||
agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService, skillService)
|
||||
teamHandler := handlers.NewTeamHandler(teamService)
|
||||
workspaceFileHandler := handlers.NewWorkspaceFileHandler(instanceService, workspaceFileService, runtimeWorkspaceFileService)
|
||||
workspaceFileHandler.SetSkillRepository(skillRepo)
|
||||
runtimeAgentHandler := handlers.NewRuntimeAgentHandler(cfg.Runtime, runtimePodRepo, bindingRepo, instanceRepo, runtimeEvents)
|
||||
runtimeAgentHandler := handlers.NewRuntimeAgentHandler(cfg.Runtime, runtimePodRepo, bindingRepo, instanceRepo, runtimeEvents, skillService)
|
||||
|
||||
// Initialize WebSocket hub and handler
|
||||
wsHub := services.GetHub()
|
||||
@@ -238,6 +252,7 @@ func main() {
|
||||
startBackground := func(ctx context.Context) {
|
||||
log.Printf("Starting leader-only background loops (identity=%s)", cfg.LeaderElection.Identity)
|
||||
syncService.Start()
|
||||
materializeWorker.Start()
|
||||
teamService.StartBackground(ctx)
|
||||
if runtimeScheduler != nil {
|
||||
runtimeSchedulerMu.Lock()
|
||||
@@ -252,6 +267,7 @@ func main() {
|
||||
}
|
||||
stopBackground := func() {
|
||||
log.Printf("Stopping leader-only background loops (identity=%s)", cfg.LeaderElection.Identity)
|
||||
materializeWorker.Stop()
|
||||
runtimeSchedulerMu.Lock()
|
||||
if runtimeSchedulerCancel != nil {
|
||||
runtimeSchedulerCancel()
|
||||
@@ -354,6 +370,8 @@ func main() {
|
||||
instances.POST("/:id/restart", instanceHandler.RestartInstance)
|
||||
instances.GET("/:id/status", instanceHandler.GetInstanceStatus)
|
||||
instances.GET("/:id/runtime", instanceHandler.GetRuntimeDetails)
|
||||
instances.GET("/:id/session-usage", instanceHandler.GetInstanceSessionUsage)
|
||||
instances.GET("/:id/session-usage/detail", instanceHandler.GetInstanceSessionUsageDetail)
|
||||
instances.POST("/:id/runtime/:command", instanceHandler.CreateRuntimeCommand)
|
||||
instances.GET("/:id/config/revisions", instanceHandler.ListConfigRevisions)
|
||||
instances.POST("/:id/config/revisions/publish", instanceHandler.PublishConfigRevision)
|
||||
@@ -379,11 +397,15 @@ func main() {
|
||||
instances.GET("/:id/skills", skillHandler.ListInstanceSkills)
|
||||
instances.GET("/:id/skills/available", skillHandler.ListAvailableInstanceSkills)
|
||||
instances.POST("/:id/skills", skillHandler.AttachSkillToInstance)
|
||||
instances.POST("/:id/skills/sync", instanceHandler.RefreshInstanceSkills)
|
||||
instances.POST("/:id/skills/:skillId/import-to-library", instanceHandler.ImportInstanceSkillToLibrary)
|
||||
instances.POST("/:id/skills/:skillId/retry-package-collect", instanceHandler.RetrySkillPackageCollect)
|
||||
instances.POST("/:id/skills/:skillId/publish-to-hub", instanceHandler.PublishInstanceSkillToHub)
|
||||
instances.DELETE("/:id/skills/:skillId", skillHandler.RemoveSkillFromInstance)
|
||||
}
|
||||
|
||||
// Admin console: cross-user instance listing. Gated by admin
|
||||
// middleware — non-admin callers get 403. The workspace
|
||||
// middleware 鈥?non-admin callers get 403. The workspace
|
||||
// /instances endpoint above stays caller-scoped regardless of
|
||||
// role; admin status only unlocks this dedicated surface.
|
||||
adminInstances := api.Group("/admin/instances")
|
||||
@@ -464,6 +486,38 @@ func main() {
|
||||
skills.GET("/:id/scan-results", skillHandler.ListScanResults)
|
||||
}
|
||||
|
||||
skillHub := api.Group("/skill-hub")
|
||||
skillHub.Use(middleware.Auth())
|
||||
skillHub.Use(middleware.SetUserInfo(userRepo))
|
||||
{
|
||||
skillHub.GET("/catalog", skillHubHandler.ListCatalog)
|
||||
skillHub.GET("/tags", skillHubHandler.ListTags)
|
||||
skillHub.GET("/mine", skillHubHandler.ListMine)
|
||||
skillHub.GET("/attachable", skillHubHandler.ListAttachable)
|
||||
skillHub.POST("/skills/import/preview", skillHubHandler.PreviewImportSkills)
|
||||
skillHub.POST("/skills/import", skillHubHandler.ImportSkills)
|
||||
skillHub.GET("/skills/:id", skillHubHandler.GetSkill)
|
||||
skillHub.POST("/skills/:id/publish", skillHubHandler.PublishSkill)
|
||||
skillHub.POST("/skills/:id/unpublish", skillHubHandler.UnpublishSkill)
|
||||
skillHub.PUT("/skills/:id/tags", skillHubHandler.UpdateTags)
|
||||
skillHub.DELETE("/skills/:id", skillHubHandler.DeleteSkill)
|
||||
skillHub.GET("/skills/:id/download", skillHubHandler.DownloadSkill)
|
||||
skillHub.POST("/skills/:id/install", skillHubHandler.InstallSkill)
|
||||
}
|
||||
|
||||
adminSkillHub := api.Group("/admin/skill-hub")
|
||||
adminSkillHub.Use(middleware.Auth())
|
||||
adminSkillHub.Use(middleware.SetUserInfo(userRepo))
|
||||
adminSkillHub.Use(middleware.NewAdminAuth(userRepo))
|
||||
{
|
||||
adminSkillHub.GET("/skills", skillHubHandler.ListAdminSkills)
|
||||
adminSkillHub.POST("/skills/:id/publish", skillHubHandler.PublishSkill)
|
||||
adminSkillHub.POST("/skills/:id/unpublish", skillHubHandler.UnpublishSkill)
|
||||
adminSkillHub.PUT("/skills/:id/tags", skillHubHandler.UpdateTags)
|
||||
adminSkillHub.DELETE("/skills/:id", skillHubHandler.DeleteSkill)
|
||||
adminSkillHub.POST("/skills/:id/install", skillHubHandler.InstallSkill)
|
||||
}
|
||||
|
||||
systemSettings := api.Group("/system-settings")
|
||||
systemSettings.Use(middleware.Auth())
|
||||
systemSettings.Use(middleware.SetUserInfo(userRepo))
|
||||
@@ -509,6 +563,22 @@ func main() {
|
||||
adminCosts.GET("", aiObservabilityHandler.GetCostOverview)
|
||||
}
|
||||
|
||||
adminLLMGovernance := api.Group("/admin/llm-governance")
|
||||
adminLLMGovernance.Use(middleware.Auth())
|
||||
adminLLMGovernance.Use(middleware.SetUserInfo(userRepo))
|
||||
adminLLMGovernance.Use(middleware.NewAdminAuth(userRepo))
|
||||
{
|
||||
adminLLMGovernance.GET("/overview", aiObservabilityHandler.GetLLMGovernanceOverview)
|
||||
}
|
||||
|
||||
adminSessionUsage := api.Group("/admin/session-usage")
|
||||
adminSessionUsage.Use(middleware.Auth())
|
||||
adminSessionUsage.Use(middleware.SetUserInfo(userRepo))
|
||||
adminSessionUsage.Use(middleware.NewAdminAuth(userRepo))
|
||||
{
|
||||
adminSessionUsage.GET("/overview", aiObservabilityHandler.GetSessionUsageOverview)
|
||||
}
|
||||
|
||||
adminRiskRules := api.Group("/admin/risk-rules")
|
||||
adminRiskRules.Use(middleware.Auth())
|
||||
adminRiskRules.Use(middleware.SetUserInfo(userRepo))
|
||||
@@ -559,7 +629,7 @@ func main() {
|
||||
agent.POST("/state/report", agentHandler.ReportState)
|
||||
agent.POST("/skills/inventory", agentHandler.ReportSkillInventory)
|
||||
agent.POST("/skills/upload", agentHandler.UploadSkillPackage)
|
||||
agent.GET("/skills/versions/:skillVersion/download", skillHandler.DownloadSkillVersionForAgent)
|
||||
agent.GET("/skills/versions/:skillVersion/download", agentHandler.DownloadSkillVersion)
|
||||
agent.GET("/config/revisions/:id", agentHandler.GetConfigRevision)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
"clawreef/internal/services"
|
||||
"clawreef/internal/utils"
|
||||
)
|
||||
|
||||
// ToolCallFunction represents a tool/function call payload.
|
||||
@@ -71,6 +72,8 @@ type ChatCompletionRequest struct {
|
||||
StreamOptions json.RawMessage `json:"stream_options,omitempty"`
|
||||
User *string `json:"user,omitempty"`
|
||||
SessionID *string `json:"session_id,omitempty"`
|
||||
OpenClawSessionKey *string `json:"-"`
|
||||
ManagedAgentType *string `json:"-"`
|
||||
InstanceID *int `json:"instance_id,omitempty"`
|
||||
InstanceMode *string `json:"instance_mode,omitempty"`
|
||||
RuntimeType *string `json:"runtime_type,omitempty"`
|
||||
@@ -429,16 +432,43 @@ func (s *service) prepareChatRequest(userID int, req ChatCompletionRequest) (*pr
|
||||
selectedModel: selectedModel,
|
||||
req: req,
|
||||
}
|
||||
prepared.sessionID = resolveSessionID(req)
|
||||
sessionSource, sessionID := resolveSessionIdentity(req)
|
||||
prepared.sessionID = sessionID
|
||||
if prepared.sessionID != "" && sessionSource != "openclaw_header" {
|
||||
if runtimeType := strings.TrimSpace(sessionNormalizationRuntimeType(req)); runtimeType != "" {
|
||||
prepared.sessionID = utils.NormalizeOpenClawSessionID(prepared.sessionID, runtimeType)
|
||||
}
|
||||
}
|
||||
prepared.traceID = s.resolveTraceID(userID, req, prepared.sessionID)
|
||||
if prepared.sessionID == "" {
|
||||
prepared.sessionID = normalizeSessionID(nil, prepared.traceID)
|
||||
sessionSource = "trace_fallback"
|
||||
}
|
||||
prepared.sessionIDPtr = stringPtr(prepared.sessionID)
|
||||
prepared.req.SessionID = prepared.sessionIDPtr
|
||||
prepared.requestID = normalizeOrCreateID(req.RequestID, "req")
|
||||
prepared.requestIDPtr = stringPtr(prepared.requestID)
|
||||
|
||||
if sessionSource == "trace_fallback" && req.InstanceID != nil {
|
||||
if err := s.auditEventService.RecordEvent(&models.AuditEvent{
|
||||
TraceID: prepared.traceID,
|
||||
SessionID: prepared.sessionIDPtr,
|
||||
RequestID: prepared.requestIDPtr,
|
||||
UserID: prepared.userIDPtr,
|
||||
InstanceID: req.InstanceID,
|
||||
InstanceMode: runtimeAttributionString(req.InstanceMode),
|
||||
RuntimeType: runtimeAttributionString(req.RuntimeType),
|
||||
GatewayID: runtimeAttributionString(req.GatewayID),
|
||||
RuntimePodID: runtimeAttributionInt64(req.RuntimePodID),
|
||||
EventType: "gateway.session.fallback",
|
||||
TrafficClass: models.TrafficClassLLM,
|
||||
Severity: models.AuditSeverityWarn,
|
||||
Message: fmt.Sprintf("LLM request missing stable session key for instance %d", *req.InstanceID),
|
||||
}); err != nil {
|
||||
logPersistenceError("record gateway.session.fallback", prepared.traceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
sessionTitle := deriveSessionTitle(prepared.req.Messages)
|
||||
if _, err := s.chatSessionService.EnsureSession(prepared.sessionID, prepared.userIDPtr, prepared.req.InstanceID, stringPtr(prepared.traceID), sessionTitle); err != nil {
|
||||
logPersistenceError("ensure chat session", prepared.traceID, err)
|
||||
@@ -2231,13 +2261,23 @@ func fallbackCurrency(currency string) string {
|
||||
}
|
||||
|
||||
func resolveSessionID(req ChatCompletionRequest) string {
|
||||
_, sessionID := resolveSessionIdentity(req)
|
||||
return sessionID
|
||||
}
|
||||
|
||||
func resolveSessionIdentity(req ChatCompletionRequest) (source string, sessionID string) {
|
||||
if req.OpenClawSessionKey != nil {
|
||||
if key := strings.TrimSpace(*req.OpenClawSessionKey); key != "" {
|
||||
return "openclaw_header", utils.NormalizeOpenClawSessionID(key, sessionNormalizationRuntimeType(req))
|
||||
}
|
||||
}
|
||||
if normalized := normalizeOptionalString(req.SessionID); normalized != "" {
|
||||
return normalizeExistingIdentifier(normalized, "sess")
|
||||
return "explicit", normalizeExistingIdentifier(normalized, "sess")
|
||||
}
|
||||
if normalized := normalizeOptionalString(req.User); normalized != "" {
|
||||
return normalizeExistingIdentifier(normalized, "sess")
|
||||
return "openai_user", normalizeExistingIdentifier(normalized, "sess")
|
||||
}
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func normalizeSessionID(value *string, traceID string) string {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package aigateway
|
||||
|
||||
import "strings"
|
||||
|
||||
const defaultManagedSessionKey = "main"
|
||||
|
||||
// IsManagedInstanceType reports whether an instance type participates in managed
|
||||
// runtime LLM governance (OpenClaw / Hermes).
|
||||
func IsManagedInstanceType(instanceType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(instanceType)) {
|
||||
case "openclaw", "hermes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HasExplicitSessionIdentity returns true when the caller supplied a stable
|
||||
// session identifier via header or request body fields.
|
||||
func HasExplicitSessionIdentity(req ChatCompletionRequest) bool {
|
||||
if req.OpenClawSessionKey != nil && strings.TrimSpace(*req.OpenClawSessionKey) != "" {
|
||||
return true
|
||||
}
|
||||
if normalizeOptionalString(req.SessionID) != "" {
|
||||
return true
|
||||
}
|
||||
if normalizeOptionalString(req.User) != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyManagedInstanceSessionDefaults fills in the default OpenClaw/Hermes session
|
||||
// key for instance gateway token calls that omitted explicit session identity.
|
||||
func ApplyManagedInstanceSessionDefaults(req *ChatCompletionRequest, gatewayAuthType, instanceType string) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(gatewayAuthType) != "instance" {
|
||||
return
|
||||
}
|
||||
instanceType = strings.TrimSpace(instanceType)
|
||||
if !IsManagedInstanceType(instanceType) {
|
||||
return
|
||||
}
|
||||
req.ManagedAgentType = stringPtr(instanceType)
|
||||
if HasExplicitSessionIdentity(*req) {
|
||||
return
|
||||
}
|
||||
req.OpenClawSessionKey = stringPtr(defaultManagedSessionKey)
|
||||
}
|
||||
|
||||
func sessionNormalizationRuntimeType(req ChatCompletionRequest) string {
|
||||
if req.ManagedAgentType != nil {
|
||||
if runtimeType := strings.TrimSpace(*req.ManagedAgentType); runtimeType != "" {
|
||||
return runtimeType
|
||||
}
|
||||
}
|
||||
if req.RuntimeType != nil {
|
||||
return strings.TrimSpace(*req.RuntimeType)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package aigateway
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyManagedInstanceSessionDefaultsUsesMainForOpenClawInstanceToken(t *testing.T) {
|
||||
req := ChatCompletionRequest{
|
||||
Messages: []ChatMessage{{Role: "user", Content: "hello"}},
|
||||
}
|
||||
ApplyManagedInstanceSessionDefaults(&req, "instance", "openclaw")
|
||||
if req.OpenClawSessionKey == nil || *req.OpenClawSessionKey != "main" {
|
||||
t.Fatalf("expected default session key main, got %+v", req.OpenClawSessionKey)
|
||||
}
|
||||
if req.ManagedAgentType == nil || *req.ManagedAgentType != "openclaw" {
|
||||
t.Fatalf("expected managed agent type openclaw, got %+v", req.ManagedAgentType)
|
||||
}
|
||||
if got := resolveSessionID(req); got != "agent:openclaw:main" {
|
||||
t.Fatalf("expected normalized session id agent:openclaw:main, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManagedInstanceSessionDefaultsUsesMainForHermesInstanceToken(t *testing.T) {
|
||||
req := ChatCompletionRequest{}
|
||||
ApplyManagedInstanceSessionDefaults(&req, "instance", "hermes")
|
||||
if got := resolveSessionID(req); got != "agent:hermes:main" {
|
||||
t.Fatalf("expected normalized session id agent:hermes:main, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManagedInstanceSessionDefaultsSkipsUserJWTCalls(t *testing.T) {
|
||||
req := ChatCompletionRequest{}
|
||||
ApplyManagedInstanceSessionDefaults(&req, "user", "openclaw")
|
||||
if req.OpenClawSessionKey != nil {
|
||||
t.Fatalf("expected no default session key for user auth, got %+v", req.OpenClawSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManagedInstanceSessionDefaultsRespectsExplicitHeader(t *testing.T) {
|
||||
explicit := "work"
|
||||
req := ChatCompletionRequest{
|
||||
OpenClawSessionKey: &explicit,
|
||||
RuntimeType: stringPtr("desktop"),
|
||||
}
|
||||
ApplyManagedInstanceSessionDefaults(&req, "instance", "openclaw")
|
||||
if got := resolveSessionID(req); got != "agent:openclaw:work" {
|
||||
t.Fatalf("expected explicit session key to win, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManagedInstanceSessionDefaultsRespectsExplicitSessionID(t *testing.T) {
|
||||
explicit := "agent:openclaw:custom"
|
||||
req := ChatCompletionRequest{
|
||||
SessionID: &explicit,
|
||||
}
|
||||
ApplyManagedInstanceSessionDefaults(&req, "instance", "openclaw")
|
||||
if req.OpenClawSessionKey != nil {
|
||||
t.Fatalf("expected body session id to prevent default header injection, got %+v", req.OpenClawSessionKey)
|
||||
}
|
||||
if got := resolveSessionID(req); got != explicit {
|
||||
t.Fatalf("expected explicit session id %q, got %q", explicit, got)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Config struct {
|
||||
Runtime RuntimePoolConfig `yaml:"runtime"`
|
||||
ObjectStorage ObjectStorageConfig `yaml:"objectStorage"`
|
||||
SkillScanner SkillScannerConfig `yaml:"skillScanner"`
|
||||
SkillMaterialize SkillMaterializeConfig `yaml:"skillMaterialize"`
|
||||
LeaderElection LeaderElectionConfig `yaml:"leaderElection"`
|
||||
}
|
||||
|
||||
@@ -200,6 +201,14 @@ type SkillScannerConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
type SkillMaterializeConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
TickMS int `yaml:"tickMs"`
|
||||
BatchSize int `yaml:"batchSize"`
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
PerInstanceConcurrency int `yaml:"perInstanceConcurrency"`
|
||||
}
|
||||
|
||||
// Load loads configuration from file and environment variables
|
||||
func Load() (*Config, error) {
|
||||
runtimeNamespace := getEnv("RUNTIME_NAMESPACE", getEnv("K8S_NAMESPACE", "clawmanager-system"))
|
||||
@@ -305,6 +314,13 @@ func Load() (*Config, error) {
|
||||
TimeoutSeconds: 30,
|
||||
Enabled: strings.EqualFold(getEnv("SKILL_SCANNER_ENABLED", "false"), "true"),
|
||||
},
|
||||
SkillMaterialize: SkillMaterializeConfig{
|
||||
Enabled: strings.EqualFold(getEnv("SKILL_MATERIALIZE_WORKER_ENABLED", "true"), "true"),
|
||||
TickMS: 2000,
|
||||
BatchSize: 5,
|
||||
Concurrency: 5,
|
||||
PerInstanceConcurrency: 2,
|
||||
},
|
||||
LeaderElection: LeaderElectionConfig{
|
||||
Enabled: strings.EqualFold(getEnv("CLAWMANAGER_LEADER_ELECTION", "true"), "true"),
|
||||
Namespace: getEnv("POD_NAMESPACE", "clawmanager-system"),
|
||||
@@ -497,6 +513,21 @@ func applyEnvOverrides(config *Config) {
|
||||
if timeoutSeconds := os.Getenv("SKILL_SCANNER_TIMEOUT_SECONDS"); timeoutSeconds != "" {
|
||||
fmt.Sscanf(timeoutSeconds, "%d", &config.SkillScanner.TimeoutSeconds)
|
||||
}
|
||||
if enabled := os.Getenv("SKILL_MATERIALIZE_WORKER_ENABLED"); enabled != "" {
|
||||
config.SkillMaterialize.Enabled = strings.EqualFold(enabled, "true")
|
||||
}
|
||||
if tickMS := os.Getenv("SKILL_MATERIALIZE_TICK_MS"); tickMS != "" {
|
||||
fmt.Sscanf(tickMS, "%d", &config.SkillMaterialize.TickMS)
|
||||
}
|
||||
if batchSize := os.Getenv("SKILL_MATERIALIZE_BATCH_SIZE"); batchSize != "" {
|
||||
fmt.Sscanf(batchSize, "%d", &config.SkillMaterialize.BatchSize)
|
||||
}
|
||||
if concurrency := os.Getenv("SKILL_MATERIALIZE_CONCURRENCY"); concurrency != "" {
|
||||
fmt.Sscanf(concurrency, "%d", &config.SkillMaterialize.Concurrency)
|
||||
}
|
||||
if perInstance := os.Getenv("SKILL_MATERIALIZE_PER_INSTANCE_CONCURRENCY"); perInstance != "" {
|
||||
fmt.Sscanf(perInstance, "%d", &config.SkillMaterialize.PerInstanceConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStorageConfig(config *Config) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
ALTER TABLE skills
|
||||
ADD COLUMN visibility ENUM('private', 'public') NOT NULL DEFAULT 'private' AFTER status,
|
||||
ADD COLUMN published_at TIMESTAMP NULL AFTER visibility,
|
||||
ADD COLUMN published_by INT NULL AFTER published_at,
|
||||
ADD INDEX idx_skills_visibility (visibility, status, source_type);
|
||||
|
||||
ALTER TABLE skills
|
||||
ADD CONSTRAINT fk_skills_published_by FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_hub_tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tag_key VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
admin_only BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_skill_hub_tags_tag_key (tag_key),
|
||||
INDEX idx_skill_hub_tags_sort (sort_order, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_hub_tag_assignments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
skill_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES skill_hub_tags(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_skill_hub_tag_assignments (skill_id, tag_id),
|
||||
INDEX idx_skill_hub_tag_assignments_tag (tag_id, skill_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO skill_hub_tags (tag_key, name, description, sort_order, admin_only) VALUES
|
||||
('productivity', 'Productivity', 'Efficiency and workflow skills', 10, FALSE),
|
||||
('coding', 'Coding', 'Software development skills', 20, FALSE),
|
||||
('browser', 'Browser', 'Browser automation skills', 30, FALSE),
|
||||
('data', 'Data', 'Data processing and analytics skills', 40, FALSE),
|
||||
('communication', 'Communication', 'Messaging and collaboration skills', 50, FALSE),
|
||||
('automation', 'Automation', 'Task automation skills', 60, FALSE),
|
||||
('research', 'Research', 'Research and information gathering skills', 70, FALSE),
|
||||
('community', 'Community', 'Community shared skills', 80, FALSE),
|
||||
('admin-curated', 'Admin Curated', 'Curated by platform administrators', 90, TRUE),
|
||||
('featured', 'Featured', 'Featured on the Skill Hub', 100, TRUE);
|
||||
|
||||
UPDATE skills SET visibility = 'private';
|
||||
@@ -0,0 +1,71 @@
|
||||
SET @instance_skills_workspace_dir_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'instance_skills'
|
||||
AND COLUMN_NAME = 'workspace_dir'
|
||||
);
|
||||
SET @instance_skills_workspace_dir_sql = IF(
|
||||
@instance_skills_workspace_dir_exists = 0,
|
||||
'ALTER TABLE instance_skills ADD COLUMN workspace_dir VARCHAR(120) NULL AFTER install_path',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE instance_skills_workspace_dir_stmt FROM @instance_skills_workspace_dir_sql;
|
||||
EXECUTE instance_skills_workspace_dir_stmt;
|
||||
DEALLOCATE PREPARE instance_skills_workspace_dir_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_package_materialize_jobs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
instance_id INT NOT NULL,
|
||||
skill_id INT NOT NULL,
|
||||
blob_id INT NOT NULL,
|
||||
workspace_dir VARCHAR(120) NOT NULL,
|
||||
content_hash VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'pending',
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL DEFAULT 5,
|
||||
last_error TEXT NULL,
|
||||
idempotency_key VARCHAR(255) NOT NULL,
|
||||
trigger_source VARCHAR(50) NOT NULL DEFAULT 'sync',
|
||||
started_at TIMESTAMP NULL,
|
||||
finished_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_sp_materialize_idempotency (idempotency_key),
|
||||
INDEX idx_sp_materialize_status_created (status, created_at),
|
||||
INDEX idx_sp_materialize_instance_status (instance_id, status),
|
||||
INDEX idx_sp_materialize_blob (blob_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
UPDATE instance_skills
|
||||
SET workspace_dir = SUBSTRING_INDEX(REPLACE(install_path, '\\', '/'), '/', -1)
|
||||
WHERE workspace_dir IS NULL
|
||||
AND install_path IS NOT NULL
|
||||
AND TRIM(install_path) <> '';
|
||||
|
||||
UPDATE instance_commands ic
|
||||
JOIN instances i ON i.id = ic.instance_id
|
||||
SET ic.status = 'cancelled',
|
||||
ic.error_message = 'superseded by skill_package_materialize_jobs'
|
||||
WHERE ic.command_type = 'collect_skill_package'
|
||||
AND ic.status IN ('pending', 'dispatched', 'running')
|
||||
AND (LOWER(TRIM(i.instance_mode)) = 'lite' OR LOWER(TRIM(i.runtime_type)) = 'gateway');
|
||||
|
||||
SET @instance_skills_workspace_dir_index_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'instance_skills'
|
||||
AND INDEX_NAME = 'idx_instance_skills_workspace_dir'
|
||||
);
|
||||
SET @instance_skills_workspace_dir_index_sql = IF(
|
||||
@instance_skills_workspace_dir_index_exists = 0,
|
||||
'ALTER TABLE instance_skills ADD INDEX idx_instance_skills_workspace_dir (workspace_dir)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE instance_skills_workspace_dir_index_stmt FROM @instance_skills_workspace_dir_index_sql;
|
||||
EXECUTE instance_skills_workspace_dir_index_stmt;
|
||||
DEALLOCATE PREPARE instance_skills_workspace_dir_index_stmt;
|
||||
@@ -0,0 +1,7 @@
|
||||
UPDATE instance_skills isk
|
||||
JOIN skills s ON s.id = isk.skill_id
|
||||
SET isk.source_type = 'injected_by_clawmanager'
|
||||
WHERE isk.source_type = 'discovered_in_instance'
|
||||
AND s.source_type = 'uploaded'
|
||||
AND s.visibility = 'public'
|
||||
AND isk.status = 'active';
|
||||
@@ -0,0 +1,52 @@
|
||||
SET @dbname = DATABASE();
|
||||
|
||||
SET @indexname = 'idx_cost_records_instance_id';
|
||||
SET @preparedStatement = (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = @dbname
|
||||
AND table_name = 'cost_records'
|
||||
AND index_name = @indexname
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE cost_records ADD INDEX idx_cost_records_instance_id (instance_id)'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @preparedStatement;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @indexname = 'idx_cost_records_session_id';
|
||||
SET @preparedStatement = (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = @dbname
|
||||
AND table_name = 'cost_records'
|
||||
AND index_name = @indexname
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE cost_records ADD INDEX idx_cost_records_session_id (session_id)'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @preparedStatement;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @indexname = 'idx_model_invocations_instance_session';
|
||||
SET @preparedStatement = (
|
||||
SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = @dbname
|
||||
AND table_name = 'model_invocations'
|
||||
AND index_name = @indexname
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE model_invocations ADD INDEX idx_model_invocations_instance_session (instance_id, session_id, created_at)'
|
||||
)
|
||||
);
|
||||
PREPARE stmt FROM @preparedStatement;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -183,3 +183,20 @@ func TestMigration038AddsGatewayTokenAliases(t *testing.T) {
|
||||
t.Fatalf("migration 038 must not store raw access tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration041AddsSessionUsageIndexes(t *testing.T) {
|
||||
raw, err := embeddedMigrations.ReadFile("migrations/041_add_session_usage_indexes.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read migration 041: %v", err)
|
||||
}
|
||||
sql := string(raw)
|
||||
for _, required := range []string{
|
||||
"idx_cost_records_instance_id",
|
||||
"idx_cost_records_session_id",
|
||||
"idx_model_invocations_instance_session",
|
||||
} {
|
||||
if !strings.Contains(sql, required) {
|
||||
t.Fatalf("migration 041 must contain %s", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package egresspolicy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeOpen Mode = "open"
|
||||
ModeDenylist Mode = "denylist"
|
||||
ModeAllowlist Mode = "allowlist"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
Mode Mode
|
||||
DeniedHostSuffixes []string
|
||||
AllowedHostSuffixes []string
|
||||
}
|
||||
|
||||
func LoadFromEnv() Policy {
|
||||
mode := Mode(strings.ToLower(strings.TrimSpace(os.Getenv("CLAWMANAGER_EGRESS_LLM_POLICY"))))
|
||||
if mode == "" {
|
||||
mode = ModeDenylist
|
||||
}
|
||||
|
||||
policy := Policy{
|
||||
Mode: mode,
|
||||
DeniedHostSuffixes: append(defaultDeniedHostSuffixes(),
|
||||
splitCSV(os.Getenv("CLAWMANAGER_EGRESS_DENIED_SUFFIXES"))...),
|
||||
AllowedHostSuffixes: append(defaultAllowedHostSuffixes(),
|
||||
splitCSV(os.Getenv("CLAWMANAGER_EGRESS_ALLOWED_SUFFIXES"))...),
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func defaultDeniedHostSuffixes() []string {
|
||||
return []string{
|
||||
"api.openai.com",
|
||||
"openai.azure.com",
|
||||
"api.anthropic.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"api.deepseek.com",
|
||||
"api.moonshot.cn",
|
||||
"open.bigmodel.cn",
|
||||
"dashscope.aliyuncs.com",
|
||||
}
|
||||
}
|
||||
|
||||
func defaultAllowedHostSuffixes() []string {
|
||||
return []string{
|
||||
"github.com",
|
||||
"registry-1.docker.io",
|
||||
"pypi.org",
|
||||
"npmjs.org",
|
||||
"clawmanager-gateway",
|
||||
"clawmanager-egress-proxy",
|
||||
}
|
||||
}
|
||||
|
||||
func (p Policy) AllowHost(host string) (bool, string) {
|
||||
host = normalizeHost(host)
|
||||
if host == "" {
|
||||
return false, "empty host"
|
||||
}
|
||||
|
||||
switch p.Mode {
|
||||
case ModeOpen, "":
|
||||
return true, ""
|
||||
case ModeAllowlist:
|
||||
if matchesAnySuffix(host, p.AllowedHostSuffixes) {
|
||||
return true, ""
|
||||
}
|
||||
return false, "host not in allowlist"
|
||||
default:
|
||||
if matchesAnySuffix(host, p.DeniedHostSuffixes) {
|
||||
return false, "llm provider host blocked"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.Index(host, ":"); idx >= 0 {
|
||||
host = host[:idx]
|
||||
}
|
||||
return strings.Trim(host, ".")
|
||||
}
|
||||
|
||||
func matchesAnySuffix(host string, suffixes []string) bool {
|
||||
for _, suffix := range suffixes {
|
||||
suffix = strings.TrimSpace(strings.ToLower(suffix))
|
||||
if suffix == "" {
|
||||
continue
|
||||
}
|
||||
if host == suffix || strings.HasSuffix(host, "."+suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package egresspolicy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPolicyDenylistBlocksOpenAI(t *testing.T) {
|
||||
policy := Policy{
|
||||
Mode: ModeDenylist,
|
||||
DeniedHostSuffixes: defaultDeniedHostSuffixes(),
|
||||
}
|
||||
allowed, reason := policy.AllowHost("api.openai.com")
|
||||
if allowed || reason == "" {
|
||||
t.Fatalf("expected openai host to be blocked, allowed=%v reason=%q", allowed, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyDenylistAllowsGitHub(t *testing.T) {
|
||||
policy := Policy{
|
||||
Mode: ModeDenylist,
|
||||
DeniedHostSuffixes: defaultDeniedHostSuffixes(),
|
||||
}
|
||||
allowed, reason := policy.AllowHost("github.com")
|
||||
if !allowed || reason != "" {
|
||||
t.Fatalf("expected github host to be allowed, allowed=%v reason=%q", allowed, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyOpenAllowsEverything(t *testing.T) {
|
||||
policy := Policy{Mode: ModeOpen}
|
||||
allowed, reason := policy.AllowHost("api.openai.com")
|
||||
if !allowed || reason != "" {
|
||||
t.Fatalf("expected open mode to allow host, allowed=%v reason=%q", allowed, reason)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -198,6 +199,7 @@ func (h *AgentHandler) ReportSkillInventory(c *gin.Context) {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
h.skillService.CompletePendingSkillInventorySync(session.Instance.ID)
|
||||
utils.Success(c, http.StatusOK, "Agent skill inventory reported successfully", nil)
|
||||
}
|
||||
|
||||
@@ -225,12 +227,26 @@ func (h *AgentHandler) UploadSkillPackage(c *gin.Context) {
|
||||
}
|
||||
item, err := h.skillService.UploadAgentSkillPackage(c.Request.Context(), session.Instance.ID, req, fileHeader)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "Agent skill package uploaded successfully", item)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) DownloadSkillVersion(c *gin.Context) {
|
||||
if _, ok := h.authenticateAgentSession(c); !ok {
|
||||
return
|
||||
}
|
||||
content, fileName, err := h.skillService.DownloadSkillVersionByExternalID(c.Param("skillVersion"))
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
c.Data(http.StatusOK, "application/octet-stream", content)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) authenticateAgentSession(c *gin.Context) (*services.AgentSession, bool) {
|
||||
sessionToken := extractBearerToken(c.GetHeader("Authorization"))
|
||||
if sessionToken == "" {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDownloadSkillVersionRequiresAgentSession(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewAgentHandler(nil, nil, nil, nil, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/agent/skills/versions/skill-version-1/download", nil)
|
||||
c.Params = gin.Params{{Key: "skillVersion", Value: "skill-version-1"}}
|
||||
|
||||
handler.DownloadSkillVersion(c)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,9 @@ func (h *AIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
req.RawBody = rawBody
|
||||
if req.SessionID == nil {
|
||||
if sessionKey := strings.TrimSpace(c.GetHeader("x-openclaw-session-key")); sessionKey != "" {
|
||||
if sessionKey := strings.TrimSpace(c.GetHeader("x-openclaw-session-key")); sessionKey != "" {
|
||||
req.OpenClawSessionKey = &sessionKey
|
||||
if req.SessionID == nil {
|
||||
req.SessionID = &sessionKey
|
||||
}
|
||||
}
|
||||
@@ -59,6 +60,13 @@ func (h *AIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
req.TraceID = &runID
|
||||
}
|
||||
}
|
||||
gatewayAuthType, _ := c.Get("gatewayAuthType")
|
||||
instanceType, _ := c.Get("instanceType")
|
||||
aigateway.ApplyManagedInstanceSessionDefaults(
|
||||
&req,
|
||||
stringValue(gatewayAuthType),
|
||||
stringValue(instanceType),
|
||||
)
|
||||
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
@@ -158,3 +166,11 @@ func setInt64Metadata(c *gin.Context, field **int64, key string) bool {
|
||||
}
|
||||
return **field == value
|
||||
}
|
||||
|
||||
func stringValue(raw interface{}) string {
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ type CostQueryRequest struct {
|
||||
Search string `form:"search"`
|
||||
}
|
||||
|
||||
// SessionUsageOverviewQueryRequest binds admin session usage overview filters.
|
||||
type SessionUsageOverviewQueryRequest struct {
|
||||
Page int `form:"page,default=1"`
|
||||
Limit int `form:"limit,default=20"`
|
||||
Search string `form:"search"`
|
||||
Since string `form:"since"`
|
||||
Until string `form:"until"`
|
||||
}
|
||||
|
||||
// NewAIObservabilityHandler creates a new observability handler.
|
||||
func NewAIObservabilityHandler(service services.AIObservabilityService) *AIObservabilityHandler {
|
||||
return &AIObservabilityHandler{service: service}
|
||||
@@ -90,3 +99,51 @@ func (h *AIObservabilityHandler) GetCostOverview(c *gin.Context) {
|
||||
|
||||
utils.Success(c, http.StatusOK, "AI cost overview retrieved successfully", overview)
|
||||
}
|
||||
|
||||
// GetLLMGovernanceOverview returns managed-runtime LLM governance summary for admins.
|
||||
func (h *AIObservabilityHandler) GetLLMGovernanceOverview(c *gin.Context) {
|
||||
overview, err := h.service.GetLLMGovernanceOverview()
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "LLM governance overview retrieved successfully", overview)
|
||||
}
|
||||
|
||||
// GetSessionUsageOverview returns cross-instance session token usage for admins.
|
||||
func (h *AIObservabilityHandler) GetSessionUsageOverview(c *gin.Context) {
|
||||
var req SessionUsageOverviewQueryRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
since, err := parseOptionalRFC3339(req.Since)
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid since timestamp")
|
||||
return
|
||||
}
|
||||
until, parseUntilErr := parseOptionalRFC3339(req.Until)
|
||||
if parseUntilErr != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid until timestamp")
|
||||
return
|
||||
}
|
||||
if err := validateSessionUsageTimeRange(since, until); err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
overview, err := h.service.GetAdminSessionUsageOverview(services.InstanceSessionUsageOverviewQuery{
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
Since: since,
|
||||
Until: until,
|
||||
})
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "Session usage overview retrieved successfully", overview)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clawreef/internal/repository"
|
||||
"clawreef/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type stubAIObservabilityHandlerService struct {
|
||||
overview *services.InstanceSessionUsageOverview
|
||||
err error
|
||||
lastQuery services.InstanceSessionUsageOverviewQuery
|
||||
}
|
||||
|
||||
func (s *stubAIObservabilityHandlerService) ListAuditItems(services.AuditQuery) (*services.AuditListResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAIObservabilityHandlerService) GetTraceDetail(string) (*services.AuditTraceDetail, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAIObservabilityHandlerService) GetCostOverview(services.CostQuery) (*services.CostOverview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAIObservabilityHandlerService) GetInstanceSessionUsage(int, services.InstanceSessionUsageQuery) (*services.InstanceSessionUsageResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAIObservabilityHandlerService) GetInstanceSessionUsageDetail(int, string, repository.SessionUsageFilter) (*services.InstanceSessionUsageDetail, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAIObservabilityHandlerService) GetInstanceLLMGovernanceStatus(int, map[string]interface{}) (*services.InstanceLLMGovernanceStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAIObservabilityHandlerService) GetLLMGovernanceOverview() (*services.LLMGovernanceOverview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAIObservabilityHandlerService) GetAdminSessionUsageOverview(query services.InstanceSessionUsageOverviewQuery) (*services.InstanceSessionUsageOverview, error) {
|
||||
s.lastQuery = query
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.overview, nil
|
||||
}
|
||||
|
||||
func TestGetSessionUsageOverviewReturns200(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/session-usage/overview?page=1&limit=10&search=oc", nil)
|
||||
|
||||
service := &stubAIObservabilityHandlerService{
|
||||
overview: &services.InstanceSessionUsageOverview{
|
||||
Summary: services.InstanceSessionUsageSummary{Currency: "USD"},
|
||||
Items: []services.InstanceSessionUsageOverviewItem{},
|
||||
Total: 0,
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
},
|
||||
}
|
||||
handler := NewAIObservabilityHandler(service)
|
||||
handler.GetSessionUsageOverview(c)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if service.lastQuery.Search != "oc" {
|
||||
t.Fatalf("expected search=oc, got %q", service.lastQuery.Search)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Session usage overview retrieved successfully") {
|
||||
t.Fatalf("unexpected body: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionUsageOverviewInvalidSince(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/session-usage/overview?since=not-a-date", nil)
|
||||
|
||||
handler := NewAIObservabilityHandler(&stubAIObservabilityHandlerService{})
|
||||
handler.GetSessionUsageOverview(c)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionUsageOverviewRejectsUntilBeforeSince(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/session-usage/overview?since=2026-07-10T00:00:00Z&until=2026-07-01T00:00:00Z", nil)
|
||||
|
||||
handler := NewAIObservabilityHandler(&stubAIObservabilityHandlerService{})
|
||||
handler.GetSessionUsageOverview(c)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,31 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/egresspolicy"
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// EgressProxyHandler provides a minimal forward proxy for ordinary HTTP/HTTPS traffic.
|
||||
type EgressProxyHandler struct {
|
||||
transport *http.Transport
|
||||
policy egresspolicy.Policy
|
||||
audit services.AuditEventService
|
||||
}
|
||||
|
||||
// NewEgressProxyHandler creates a new egress proxy handler.
|
||||
func NewEgressProxyHandler() *EgressProxyHandler {
|
||||
func NewEgressProxyHandler(audit services.AuditEventService) *EgressProxyHandler {
|
||||
return &EgressProxyHandler{
|
||||
transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
@@ -27,6 +36,8 @@ func NewEgressProxyHandler() *EgressProxyHandler {
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
policy: egresspolicy.LoadFromEnv(),
|
||||
audit: audit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +53,12 @@ func (h *EgressProxyHandler) Handle(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if allowed, reason := h.policy.AllowHost(c.Request.URL.Host); !allowed {
|
||||
h.recordBlockedEgress(c, c.Request.URL.Host, reason)
|
||||
c.String(http.StatusForbidden, "egress blocked: %s (%s)", c.Request.URL.Host, reason)
|
||||
return
|
||||
}
|
||||
|
||||
outReq := c.Request.Clone(c.Request.Context())
|
||||
outReq.RequestURI = ""
|
||||
removeHopHeaders(outReq.Header)
|
||||
@@ -66,6 +83,12 @@ func (h *EgressProxyHandler) handleConnect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if allowed, reason := h.policy.AllowHost(target); !allowed {
|
||||
h.recordBlockedEgress(c, target, reason)
|
||||
c.String(http.StatusForbidden, "egress blocked: %s (%s)", target, reason)
|
||||
return
|
||||
}
|
||||
|
||||
upstreamConn, err := net.DialTimeout("tcp", target, 30*time.Second)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "proxy connect error: %v", err)
|
||||
@@ -91,6 +114,41 @@ func (h *EgressProxyHandler) handleConnect(c *gin.Context) {
|
||||
go tunnelConns(clientConn, upstreamConn)
|
||||
}
|
||||
|
||||
func (h *EgressProxyHandler) recordBlockedEgress(c *gin.Context, host, reason string) {
|
||||
if h.audit == nil {
|
||||
return
|
||||
}
|
||||
instanceID := resolveEgressInstanceID(c)
|
||||
remoteAddr := strings.TrimSpace(c.Request.RemoteAddr)
|
||||
message := fmt.Sprintf("Blocked egress to %s (%s) from %s", host, reason, remoteAddr)
|
||||
if err := h.audit.RecordEvent(&models.AuditEvent{
|
||||
TraceID: fmt.Sprintf("egress_%d", time.Now().UnixNano()),
|
||||
InstanceID: instanceID,
|
||||
EventType: "egress.llm.blocked",
|
||||
TrafficClass: models.TrafficClassGenericEgress,
|
||||
Severity: models.AuditSeverityWarn,
|
||||
Message: message,
|
||||
}); err != nil {
|
||||
log.Printf("failed to record egress block audit event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEgressInstanceID(c *gin.Context) *int {
|
||||
for _, headerName := range []string{
|
||||
"X-ClawManager-Instance-Id",
|
||||
"X-ClawManager-Egress-Instance-Id",
|
||||
} {
|
||||
raw := strings.TrimSpace(c.GetHeader(headerName))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
||||
return &parsed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tunnelConns(dst net.Conn, src net.Conn) {
|
||||
defer dst.Close()
|
||||
defer src.Close()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"clawreef/internal/egresspolicy"
|
||||
"clawreef/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type stubEgressAuditService struct {
|
||||
events []*models.AuditEvent
|
||||
}
|
||||
|
||||
func (s *stubEgressAuditService) RecordEvent(event *models.AuditEvent) error {
|
||||
s.events = append(s.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEgressAuditService) ListEventsByTraceID(string) ([]models.AuditEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestEgressProxyHandlerBlocksDeniedConnectHost(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
audit := &stubEgressAuditService{}
|
||||
handler := &EgressProxyHandler{
|
||||
policy: egresspolicy.Policy{
|
||||
Mode: egresspolicy.ModeDenylist,
|
||||
DeniedHostSuffixes: []string{"api.openai.com"},
|
||||
},
|
||||
audit: audit,
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodConnect, "https://api.openai.com:443", nil)
|
||||
ctx.Request.Host = "api.openai.com:443"
|
||||
ctx.Request.Header.Set("X-ClawManager-Instance-Id", "42")
|
||||
|
||||
handler.handleConnect(ctx)
|
||||
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", recorder.Code)
|
||||
}
|
||||
if len(audit.events) != 1 || audit.events[0].EventType != "egress.llm.blocked" {
|
||||
t.Fatalf("expected egress audit event, got %+v", audit.events)
|
||||
}
|
||||
if audit.events[0].InstanceID == nil || *audit.events[0].InstanceID != 42 {
|
||||
t.Fatalf("expected instance id 42 on egress audit event, got %+v", audit.events[0].InstanceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressProxyHandlerAcceptsEgressInstanceHeaderAlias(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
audit := &stubEgressAuditService{}
|
||||
handler := &EgressProxyHandler{
|
||||
policy: egresspolicy.Policy{
|
||||
Mode: egresspolicy.ModeDenylist,
|
||||
DeniedHostSuffixes: []string{"api.openai.com"},
|
||||
},
|
||||
audit: audit,
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodConnect, "https://api.openai.com:443", nil)
|
||||
ctx.Request.Host = "api.openai.com:443"
|
||||
ctx.Request.Header.Set("X-ClawManager-Egress-Instance-Id", "77")
|
||||
|
||||
handler.handleConnect(ctx)
|
||||
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", recorder.Code)
|
||||
}
|
||||
if audit.events[0].InstanceID == nil || *audit.events[0].InstanceID != 77 {
|
||||
t.Fatalf("expected instance id 77 on egress audit event, got %+v", audit.events[0].InstanceID)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
"clawreef/internal/services"
|
||||
"clawreef/internal/utils"
|
||||
|
||||
@@ -135,10 +136,11 @@ type InstanceHandler struct {
|
||||
openClawConfigService services.OpenClawConfigService
|
||||
skillService services.SkillService
|
||||
externalAccessService services.InstanceExternalAccessService
|
||||
aiObservabilityService services.AIObservabilityService
|
||||
}
|
||||
|
||||
// NewInstanceHandler creates a new instance handler
|
||||
func NewInstanceHandler(instanceService services.InstanceService, instanceAgentService services.InstanceAgentService, runtimeStatusService services.InstanceRuntimeStatusService, instanceCommandService services.InstanceCommandService, instanceConfigRevisionService services.InstanceConfigRevisionService, openClawConfigService services.OpenClawConfigService, skillService services.SkillService, externalAccessService services.InstanceExternalAccessService, proxyOptions ...services.InstanceProxyServiceOption) *InstanceHandler {
|
||||
func NewInstanceHandler(instanceService services.InstanceService, instanceAgentService services.InstanceAgentService, runtimeStatusService services.InstanceRuntimeStatusService, instanceCommandService services.InstanceCommandService, instanceConfigRevisionService services.InstanceConfigRevisionService, openClawConfigService services.OpenClawConfigService, skillService services.SkillService, externalAccessService services.InstanceExternalAccessService, aiObservabilityService services.AIObservabilityService, proxyOptions ...services.InstanceProxyServiceOption) *InstanceHandler {
|
||||
accessService := services.NewInstanceAccessService()
|
||||
return &InstanceHandler{
|
||||
instanceService: instanceService,
|
||||
@@ -153,6 +155,7 @@ func NewInstanceHandler(instanceService services.InstanceService, instanceAgentS
|
||||
openClawConfigService: openClawConfigService,
|
||||
skillService: skillService,
|
||||
externalAccessService: externalAccessService,
|
||||
aiObservabilityService: aiObservabilityService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +167,10 @@ func (h *InstanceHandler) Shutdown() {
|
||||
}
|
||||
|
||||
type InstanceRuntimeDetailsResponse struct {
|
||||
Runtime *services.InstanceRuntimeStatusPayload `json:"runtime,omitempty"`
|
||||
Agent *services.InstanceAgentPayload `json:"agent,omitempty"`
|
||||
Commands []services.InstanceCommandPayload `json:"commands,omitempty"`
|
||||
Runtime *services.InstanceRuntimeStatusPayload `json:"runtime,omitempty"`
|
||||
Agent *services.InstanceAgentPayload `json:"agent,omitempty"`
|
||||
Commands []services.InstanceCommandPayload `json:"commands,omitempty"`
|
||||
LLMGovernance *services.InstanceLLMGovernanceStatus `json:"llm_governance,omitempty"`
|
||||
}
|
||||
|
||||
type CreateRuntimeCommandRequest struct {
|
||||
@@ -364,9 +368,10 @@ func (h *InstanceHandler) CreateInstance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userRole, _ := c.Get("userRole")
|
||||
for _, skillID := range skillIDs {
|
||||
if _, err := h.skillService.AttachSkillToInstance(instance.ID, skillID); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
if _, err := h.skillService.AttachSkillToInstance(userID.(int), userRole.(string), instance.ID, skillID); err != nil {
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -400,6 +405,7 @@ func instanceCreateRequestToService(req CreateInstanceRequest) services.CreateIn
|
||||
|
||||
func (h *InstanceHandler) BatchCreateLiteInstances(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
|
||||
var req BatchCreateLiteInstancesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -449,7 +455,7 @@ func (h *InstanceHandler) BatchCreateLiteInstances(c *gin.Context) {
|
||||
}
|
||||
attachFailed := false
|
||||
for _, skillID := range skillIDs {
|
||||
if _, err := h.skillService.AttachSkillToInstance(instance.ID, skillID); err != nil {
|
||||
if _, err := h.skillService.AttachSkillToInstance(userID.(int), userRole.(string), instance.ID, skillID); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
response.Failed++
|
||||
@@ -986,7 +992,7 @@ func availabilityForInstanceStatus(status string) string {
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) GetRuntimeDetails(c *gin.Context) {
|
||||
id, _, ok := h.resolveOwnedInstance(c)
|
||||
id, instance, ok := h.resolveOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -1007,11 +1013,128 @@ func (h *InstanceHandler) GetRuntimeDetails(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "Instance runtime details retrieved successfully", InstanceRuntimeDetailsResponse{
|
||||
response := InstanceRuntimeDetailsResponse{
|
||||
Runtime: runtime,
|
||||
Agent: agent,
|
||||
Commands: commands,
|
||||
}
|
||||
if h.aiObservabilityService != nil && instance != nil &&
|
||||
(instance.Type == "openclaw" || instance.Type == "hermes") {
|
||||
var systemInfo map[string]interface{}
|
||||
if runtime != nil {
|
||||
systemInfo = runtime.SystemInfo
|
||||
}
|
||||
if governance, govErr := h.aiObservabilityService.GetInstanceLLMGovernanceStatus(id, systemInfo); govErr == nil {
|
||||
response.LLMGovernance = governance
|
||||
}
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "Instance runtime details retrieved successfully", response)
|
||||
}
|
||||
|
||||
type SessionUsageQueryRequest struct {
|
||||
Page int `form:"page,default=1"`
|
||||
Limit int `form:"limit,default=20"`
|
||||
Search string `form:"search"`
|
||||
Since string `form:"since"`
|
||||
Until string `form:"until"`
|
||||
}
|
||||
|
||||
type SessionUsageDetailQueryRequest struct {
|
||||
SessionID string `form:"session_id" binding:"required"`
|
||||
Since string `form:"since"`
|
||||
Until string `form:"until"`
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) GetInstanceSessionUsage(c *gin.Context) {
|
||||
id, _, ok := h.resolveOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.aiObservabilityService == nil {
|
||||
utils.Error(c, http.StatusInternalServerError, "Session usage service is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req SessionUsageQueryRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
since, err := parseOptionalRFC3339(req.Since)
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid since timestamp")
|
||||
return
|
||||
}
|
||||
until, parseUntilErr := parseOptionalRFC3339(req.Until)
|
||||
if parseUntilErr != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid until timestamp")
|
||||
return
|
||||
}
|
||||
if err := validateSessionUsageTimeRange(since, until); err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.aiObservabilityService.GetInstanceSessionUsage(id, services.InstanceSessionUsageQuery{
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
Since: since,
|
||||
Until: until,
|
||||
})
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "Instance session usage retrieved successfully", result)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) GetInstanceSessionUsageDetail(c *gin.Context) {
|
||||
id, _, ok := h.resolveOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.aiObservabilityService == nil {
|
||||
utils.Error(c, http.StatusInternalServerError, "Session usage service is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req SessionUsageDetailQueryRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
since, err := parseOptionalRFC3339(req.Since)
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid since timestamp")
|
||||
return
|
||||
}
|
||||
until, parseUntilErr := parseOptionalRFC3339(req.Until)
|
||||
if parseUntilErr != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid until timestamp")
|
||||
return
|
||||
}
|
||||
if err := validateSessionUsageTimeRange(since, until); err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.aiObservabilityService.GetInstanceSessionUsageDetail(id, req.SessionID, repository.SessionUsageFilter{
|
||||
Since: since,
|
||||
Until: until,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
utils.Error(c, http.StatusNotFound, "Session usage not found")
|
||||
return
|
||||
}
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "Instance session usage detail retrieved successfully", detail)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) CreateRuntimeCommand(c *gin.Context) {
|
||||
@@ -1143,25 +1266,37 @@ func (h *InstanceHandler) resolveOwnedInstance(c *gin.Context) (int, *models.Ins
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
|
||||
return 0, nil, false
|
||||
}
|
||||
instance, ok := h.authorizeInstanceAccess(c, id)
|
||||
if !ok {
|
||||
return 0, nil, false
|
||||
}
|
||||
return id, instance, true
|
||||
}
|
||||
|
||||
instance, err := h.instanceService.GetByID(id)
|
||||
func (h *InstanceHandler) authorizeInstanceAccess(c *gin.Context, instanceID int) (*models.Instance, bool) {
|
||||
if instanceID <= 0 {
|
||||
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
instance, err := h.instanceService.GetByID(instanceID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return 0, nil, false
|
||||
return nil, false
|
||||
}
|
||||
if instance == nil {
|
||||
utils.Error(c, http.StatusNotFound, "Instance not found")
|
||||
return 0, nil, false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
if userRole != "admin" && instance.UserID != userID.(int) {
|
||||
utils.Error(c, http.StatusForbidden, "Access denied")
|
||||
return 0, nil, false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return id, instance, true
|
||||
return instance, true
|
||||
}
|
||||
|
||||
// GenerateAccessToken generates an access token for an instance
|
||||
@@ -1634,6 +1769,110 @@ func (h *InstanceHandler) ImportHermes(c *gin.Context) {
|
||||
utils.Success(c, http.StatusOK, "Hermes workspace imported successfully", nil)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) RefreshInstanceSkills(c *gin.Context) {
|
||||
instance, ok := h.requireOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if instance.Type != "openclaw" && instance.Type != "hermes" {
|
||||
utils.Error(c, http.StatusBadRequest, "skill inventory sync is only available for openclaw and hermes instances")
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("userID")
|
||||
issuedBy := userID.(int)
|
||||
command, err := h.createSkillInventorySyncCommand(instance, issuedBy)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill inventory sync requested", command)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) createSkillInventorySyncCommand(instance *models.Instance, issuedBy int) (*services.InstanceCommandPayload, error) {
|
||||
command, err := h.instanceCommandService.Create(instance.ID, &issuedBy, services.CreateInstanceCommandRequest{
|
||||
CommandType: services.InstanceCommandTypeSyncSkillInventory,
|
||||
Payload: map[string]interface{}{
|
||||
"trigger": "manual",
|
||||
"mode": "full",
|
||||
},
|
||||
IdempotencyKey: fmt.Sprintf("sync-skill-inventory-%d-%d", instance.ID, time.Now().Unix()),
|
||||
TimeoutSeconds: 300,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if services.IsLiteRuntimeInstance(instance) || services.SupportsServerWorkspaceSkillScan(instance) {
|
||||
if err := h.skillService.RequestLiteSkillInventorySync(instance.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) ImportInstanceSkillToLibrary(c *gin.Context) {
|
||||
instance, ok := h.requireOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
skillID, err := strconv.Atoi(c.Param("skillId"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
item, err := h.skillService.ImportInstanceSkillToLibrary(userID.(int), userRole.(string), instance.ID, skillID)
|
||||
if err != nil {
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill imported to library successfully", item)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) RetrySkillPackageCollect(c *gin.Context) {
|
||||
instance, ok := h.requireOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
skillID, err := strconv.Atoi(c.Param("skillId"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
if err := h.skillService.RetrySkillPackageCollection(userID.(int), userRole.(string), instance.ID, skillID); err != nil {
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusAccepted, "Skill package collection requested", gin.H{"status": "pending"})
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) PublishInstanceSkillToHub(c *gin.Context) {
|
||||
instance, ok := h.requireOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
skillID, err := strconv.Atoi(c.Param("skillId"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
var req services.PublishSkillHubRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
item, err := h.skillService.PublishFromInstance(userID.(int), userRole.(string), instance.ID, skillID, req.TagIDs)
|
||||
if err != nil {
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill published to hub successfully", item)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) requireOwnedInstance(c *gin.Context) (*models.Instance, bool) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
"clawreef/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -323,3 +325,166 @@ func TestBatchDeleteLiteInstancesRejectsProInstance(t *testing.T) {
|
||||
t.Fatalf("response did not explain lite-only rejection: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type stubSessionUsageObservabilityService struct {
|
||||
usage *services.InstanceSessionUsageResult
|
||||
detail *services.InstanceSessionUsageDetail
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubSessionUsageObservabilityService) ListAuditItems(services.AuditQuery) (*services.AuditListResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageObservabilityService) GetTraceDetail(string) (*services.AuditTraceDetail, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageObservabilityService) GetCostOverview(services.CostQuery) (*services.CostOverview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageObservabilityService) GetInstanceSessionUsage(int, services.InstanceSessionUsageQuery) (*services.InstanceSessionUsageResult, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.usage, nil
|
||||
}
|
||||
func (s *stubSessionUsageObservabilityService) GetInstanceSessionUsageDetail(int, string, repository.SessionUsageFilter) (*services.InstanceSessionUsageDetail, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.detail == nil {
|
||||
return nil, fmt.Errorf("session usage not found")
|
||||
}
|
||||
return s.detail, nil
|
||||
}
|
||||
func (s *stubSessionUsageObservabilityService) GetInstanceLLMGovernanceStatus(int, map[string]interface{}) (*services.InstanceLLMGovernanceStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageObservabilityService) GetLLMGovernanceOverview() (*services.LLMGovernanceOverview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageObservabilityService) GetAdminSessionUsageOverview(services.InstanceSessionUsageOverviewQuery) (*services.InstanceSessionUsageOverview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageReturns200(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/9/session-usage?page=1&limit=10", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "9"}}
|
||||
c.Set("userID", 7)
|
||||
c.Set("userRole", "user")
|
||||
|
||||
handler := &InstanceHandler{
|
||||
instanceService: &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
|
||||
9: {ID: 9, UserID: 7, Name: "openclaw-lite", Type: "openclaw"},
|
||||
}},
|
||||
aiObservabilityService: &stubSessionUsageObservabilityService{
|
||||
usage: &services.InstanceSessionUsageResult{
|
||||
Summary: services.InstanceSessionUsageSummary{Currency: "USD"},
|
||||
Items: []services.InstanceSessionUsageItem{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler.GetInstanceSessionUsage(c)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Instance session usage retrieved successfully") {
|
||||
t.Fatalf("unexpected body: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageInvalidSince(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/9/session-usage?since=bad-timestamp", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "9"}}
|
||||
c.Set("userID", 7)
|
||||
c.Set("userRole", "user")
|
||||
|
||||
handler := &InstanceHandler{
|
||||
instanceService: &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
|
||||
9: {ID: 9, UserID: 7, Name: "openclaw-lite", Type: "openclaw"},
|
||||
}},
|
||||
aiObservabilityService: &stubSessionUsageObservabilityService{},
|
||||
}
|
||||
|
||||
handler.GetInstanceSessionUsage(c)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageRejectsUntilBeforeSince(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/9/session-usage?since=2026-07-10T00:00:00Z&until=2026-07-01T00:00:00Z", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "9"}}
|
||||
c.Set("userID", 7)
|
||||
c.Set("userRole", "user")
|
||||
|
||||
handler := &InstanceHandler{
|
||||
instanceService: &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
|
||||
9: {ID: 9, UserID: 7, Name: "openclaw-lite", Type: "openclaw"},
|
||||
}},
|
||||
aiObservabilityService: &stubSessionUsageObservabilityService{},
|
||||
}
|
||||
|
||||
handler.GetInstanceSessionUsage(c)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageDetailRequiresSessionID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/9/session-usage/detail", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "9"}}
|
||||
c.Set("userID", 7)
|
||||
c.Set("userRole", "user")
|
||||
|
||||
handler := &InstanceHandler{
|
||||
instanceService: &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
|
||||
9: {ID: 9, UserID: 7, Name: "openclaw-lite", Type: "openclaw"},
|
||||
}},
|
||||
aiObservabilityService: &stubSessionUsageObservabilityService{},
|
||||
}
|
||||
|
||||
handler.GetInstanceSessionUsageDetail(c)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageDetailNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/instances/9/session-usage/detail?session_id=missing", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "9"}}
|
||||
c.Set("userID", 7)
|
||||
c.Set("userRole", "user")
|
||||
|
||||
handler := &InstanceHandler{
|
||||
instanceService: &fakeWorkspaceHandlerInstanceService{instances: map[int]*models.Instance{
|
||||
9: {ID: 9, UserID: 7, Name: "openclaw-lite", Type: "openclaw"},
|
||||
}},
|
||||
aiObservabilityService: &stubSessionUsageObservabilityService{},
|
||||
}
|
||||
|
||||
handler.GetInstanceSessionUsageDetail(c)
|
||||
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d, body = %s", recorder.Code, http.StatusNotFound, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type RuntimeAgentHandler struct {
|
||||
bindingRepo repository.InstanceRuntimeBindingRepository
|
||||
instanceRepo repository.InstanceRepository
|
||||
events runtimeEventPublisher
|
||||
skillService services.SkillService
|
||||
}
|
||||
|
||||
type runtimeAgentPodIdentity struct {
|
||||
@@ -89,13 +90,14 @@ type runtimeAgentGatewayReport struct {
|
||||
HealthAt *time.Time `json:"health_at,omitempty"`
|
||||
}
|
||||
|
||||
func NewRuntimeAgentHandler(cfg config.RuntimePoolConfig, podRepo repository.RuntimePodRepository, bindingRepo repository.InstanceRuntimeBindingRepository, instanceRepo repository.InstanceRepository, events runtimeEventPublisher) *RuntimeAgentHandler {
|
||||
func NewRuntimeAgentHandler(cfg config.RuntimePoolConfig, podRepo repository.RuntimePodRepository, bindingRepo repository.InstanceRuntimeBindingRepository, instanceRepo repository.InstanceRepository, events runtimeEventPublisher, skillService services.SkillService) *RuntimeAgentHandler {
|
||||
return &RuntimeAgentHandler{
|
||||
cfg: cfg,
|
||||
podRepo: podRepo,
|
||||
bindingRepo: bindingRepo,
|
||||
instanceRepo: instanceRepo,
|
||||
events: events,
|
||||
skillService: skillService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +353,12 @@ func (h *RuntimeAgentHandler) ReportSkills(c *gin.Context) {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
if h.skillService != nil {
|
||||
if err := h.skillService.SyncRuntimeAgentSkillsReport(payload); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.publish(c.Request.Context(), "runtime_agent_skills_reported", payload)
|
||||
utils.Success(c, http.StatusOK, "Runtime agent skills report accepted", nil)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func TestRuntimeAgentHandlerRejectsInvalidToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
podRepo := &runtimeAgentHandlerPodRepo{}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, &runtimeAgentHandlerEvents{})
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, &runtimeAgentHandlerEvents{}, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/metrics/report", handler.ReportMetrics)
|
||||
@@ -44,7 +44,7 @@ func TestRuntimeAgentHandlerRegisterUsesConfiguredCapacity(t *testing.T) {
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{
|
||||
AgentReportToken: "secret",
|
||||
MaxGatewaysPerPod: 33,
|
||||
}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
|
||||
}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/register", handler.Register)
|
||||
@@ -95,7 +95,7 @@ func TestRuntimeAgentHandlerHeartbeatUsesConfiguredCapacity(t *testing.T) {
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{
|
||||
AgentReportToken: "secret",
|
||||
MaxGatewaysPerPod: 44,
|
||||
}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
|
||||
}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/heartbeat", handler.Heartbeat)
|
||||
@@ -134,7 +134,7 @@ func TestRuntimeAgentHandlerMetricsReportUpdatesPodAndPublishesEvent(t *testing.
|
||||
gin.SetMode(gin.TestMode)
|
||||
podRepo := &runtimeAgentHandlerPodRepo{}
|
||||
events := &runtimeAgentHandlerEvents{}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events)
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, podRepo, &runtimeAgentHandlerBindingRepo{}, nil, events, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/metrics/report", handler.ReportMetrics)
|
||||
@@ -187,7 +187,7 @@ func TestRuntimeAgentHandlerGatewayReportOnlyUpdatesCurrentPodBinding(t *testing
|
||||
12: {InstanceID: 12, RuntimePodID: 9, Generation: 3},
|
||||
},
|
||||
}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, nil, &runtimeAgentHandlerEvents{})
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, nil, &runtimeAgentHandlerEvents{}, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/gateways/report", handler.ReportGateways)
|
||||
@@ -228,7 +228,7 @@ func TestRuntimeAgentHandlerGatewayReportSyncsInstanceRuntimeState(t *testing.T)
|
||||
},
|
||||
}
|
||||
instanceRepo := &runtimeAgentHandlerInstanceRepo{}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, instanceRepo, &runtimeAgentHandlerEvents{})
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret"}, &runtimeAgentHandlerPodRepo{}, bindingRepo, instanceRepo, &runtimeAgentHandlerEvents{}, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/gateways/report", handler.ReportGateways)
|
||||
@@ -274,7 +274,7 @@ func TestRuntimeAgentHandlerGatewayReportDeletesMissingCurrentPodBinding(t *test
|
||||
12: {InstanceID: 12, RuntimePodID: 9, Generation: 2, State: "error"},
|
||||
},
|
||||
}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret", HeartbeatTimeout: 10 * time.Second}, &runtimeAgentHandlerPodRepo{}, bindingRepo, nil, &runtimeAgentHandlerEvents{})
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret", HeartbeatTimeout: 10 * time.Second}, &runtimeAgentHandlerPodRepo{}, bindingRepo, nil, &runtimeAgentHandlerEvents{}, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/gateways/report", handler.ReportGateways)
|
||||
@@ -304,7 +304,7 @@ func TestRuntimeAgentHandlerGatewayReportDoesNotDeleteFreshBindingFromFirstEmpty
|
||||
10: {InstanceID: 10, RuntimePodID: 9, Generation: 3, State: "running", LastHealthAt: &lastHealthAt},
|
||||
},
|
||||
}
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret", HeartbeatTimeout: 10 * time.Second}, &runtimeAgentHandlerPodRepo{}, bindingRepo, nil, &runtimeAgentHandlerEvents{})
|
||||
handler := NewRuntimeAgentHandler(config.RuntimePoolConfig{AgentReportToken: "secret", HeartbeatTimeout: 10 * time.Second}, &runtimeAgentHandlerPodRepo{}, bindingRepo, nil, &runtimeAgentHandlerEvents{}, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/runtime-agent/gateways/report", handler.ReportGateways)
|
||||
|
||||
@@ -536,3 +536,6 @@ func (c *runtimePoolHandlerAgentClient) DeleteGateway(ctx context.Context, endpo
|
||||
return nil
|
||||
}
|
||||
func (c *runtimePoolHandlerAgentClient) Drain(ctx context.Context, endpoint string) error { return nil }
|
||||
func (c *runtimePoolHandlerAgentClient) ResyncInstanceSkills(ctx context.Context, endpoint string, instanceID int, mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseOptionalRFC3339(value string) (*time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
utc := parsed.UTC()
|
||||
return &utc, nil
|
||||
}
|
||||
|
||||
func validateSessionUsageTimeRange(since, until *time.Time) error {
|
||||
if since != nil && until != nil && !until.After(*since) {
|
||||
return fmt.Errorf("until must be after since")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateSessionUsageTimeRange(t *testing.T) {
|
||||
since := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
until := time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
|
||||
if err := validateSessionUsageTimeRange(&since, &until); err != nil {
|
||||
t.Fatalf("expected valid range, got %v", err)
|
||||
}
|
||||
if err := validateSessionUsageTimeRange(&since, &since); err == nil {
|
||||
t.Fatalf("expected equal since/until to be rejected")
|
||||
}
|
||||
if err := validateSessionUsageTimeRange(nil, &until); err != nil {
|
||||
t.Fatalf("expected open-ended range, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalRFC3339(t *testing.T) {
|
||||
if _, err := parseOptionalRFC3339(""); err != nil {
|
||||
t.Fatalf("empty value should be allowed: %v", err)
|
||||
}
|
||||
parsed, err := parseOptionalRFC3339("2026-07-01T00:00:00Z")
|
||||
if err != nil || parsed == nil {
|
||||
t.Fatalf("expected parsed timestamp, got %v err=%v", parsed, err)
|
||||
}
|
||||
if _, err := parseOptionalRFC3339("not-a-date"); err == nil {
|
||||
t.Fatalf("expected invalid timestamp error")
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,13 @@ func (h *SkillHandler) ListAllSkills(c *gin.Context) {
|
||||
|
||||
func (h *SkillHandler) GetSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
item, err := h.service.GetSkill(userID.(int), skillID)
|
||||
item, err := h.service.GetSkill(userID.(int), userRole.(string), skillID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
@@ -91,12 +92,13 @@ func (h *SkillHandler) UpdateSkill(c *gin.Context) {
|
||||
|
||||
func (h *SkillHandler) DeleteSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteSkill(userID.(int), skillID); err != nil {
|
||||
if err := h.service.DeleteSkill(userID.(int), userRole.(string), skillID); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -105,12 +107,13 @@ func (h *SkillHandler) DeleteSkill(c *gin.Context) {
|
||||
|
||||
func (h *SkillHandler) DownloadSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
content, fileName, err := h.service.DownloadSkill(userID.(int), skillID)
|
||||
content, fileName, err := h.service.DownloadSkill(userID.(int), userRole.(string), skillID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
@@ -120,25 +123,15 @@ func (h *SkillHandler) DownloadSkill(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/zip", content)
|
||||
}
|
||||
|
||||
func (h *SkillHandler) DownloadSkillVersionForAgent(c *gin.Context) {
|
||||
content, fileName, err := h.service.DownloadSkillVersionByExternalID(c.Param("skillVersion"))
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
c.Data(http.StatusOK, "application/octet-stream", content)
|
||||
}
|
||||
|
||||
func (h *SkillHandler) ListVersions(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListVersions(userID.(int), skillID)
|
||||
items, err := h.service.ListVersions(userID.(int), userRole.(string), skillID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
@@ -148,12 +141,13 @@ func (h *SkillHandler) ListVersions(c *gin.Context) {
|
||||
|
||||
func (h *SkillHandler) ListScanResults(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListScanResults(userID.(int), skillID)
|
||||
items, err := h.service.ListScanResults(userID.(int), userRole.(string), skillID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
@@ -200,9 +194,9 @@ func (h *SkillHandler) AttachSkillToInstance(c *gin.Context) {
|
||||
}
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
item, err := h.service.AttachSkillToInstanceForActor(instanceID, req.SkillID, userID.(int), fmt.Sprint(userRole))
|
||||
item, err := h.service.AttachSkillToInstance(userID.(int), userRole.(string), instanceID, req.SkillID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "Skill attached to instance successfully", item)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"clawreef/internal/services"
|
||||
"clawreef/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SkillHubHandler struct {
|
||||
service services.SkillService
|
||||
instanceService services.InstanceService
|
||||
}
|
||||
|
||||
func NewSkillHubHandler(service services.SkillService, instanceService services.InstanceService) *SkillHubHandler {
|
||||
return &SkillHubHandler{service: service, instanceService: instanceService}
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) ListCatalog(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
query := services.SkillHubCatalogQuery{
|
||||
TagKeys: c.QueryArray("tag_keys"),
|
||||
Search: strings.TrimSpace(c.Query("q")),
|
||||
Page: parseIntDefault(c.Query("page"), 1),
|
||||
PageSize: parseIntDefault(c.Query("page_size"), 20),
|
||||
}
|
||||
result, err := h.service.ListHubCatalog(userID.(int), userRole.(string), query)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill hub catalog retrieved successfully", result)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) ListTags(c *gin.Context) {
|
||||
userRole, _ := c.Get("userRole")
|
||||
items, err := h.service.ListHubTags(userRole.(string))
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill hub tags retrieved successfully", items)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) ListMine(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
items, err := h.service.ListMyHubSkills(userID.(int))
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "My skill hub items retrieved successfully", items)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) ListAttachable(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
items, err := h.service.ListAttachableSkills(userID.(int), userRole.(string))
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Attachable skills retrieved successfully", items)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) GetSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
item, err := h.service.GetSkillHubDetail(userID.(int), userRole.(string), skillID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill hub item retrieved successfully", item)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) PreviewImportSkills(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
items, err := h.service.PreviewHubImport(c.Request.Context(), userID.(int), fileHeader)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill import preview generated successfully", items)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) ImportSkills(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
var decisions []services.SkillImportDecision
|
||||
if raw := strings.TrimSpace(c.PostForm("decisions")); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &decisions); err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid decisions payload")
|
||||
return
|
||||
}
|
||||
}
|
||||
items, err := h.service.ImportHubArchiveWithDecisions(c.Request.Context(), userID.(int), fileHeader, decisions)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "Skills imported successfully", items)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) PublishSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
var req services.PublishSkillHubRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
item, err := h.service.PublishToHub(userID.(int), userRole.(string), skillID, req.TagIDs)
|
||||
if err != nil {
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill published to hub successfully", item)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) UnpublishSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UnpublishFromHub(userID.(int), userRole.(string), skillID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill unpublished from hub successfully", item)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) UpdateTags(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
var req services.UpdateSkillHubTagsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateHubTags(userID.(int), userRole.(string), skillID, req.TagIDs)
|
||||
if err != nil {
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill hub tags updated successfully", item)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) DeleteSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteSkill(userID.(int), userRole.(string), skillID); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Skill deleted successfully", nil)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) DownloadSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
content, fileName, err := h.service.DownloadSkill(userID.(int), userRole.(string), skillID)
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
c.Data(http.StatusOK, "application/zip", content)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) InstallSkill(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
userRole, _ := c.Get("userRole")
|
||||
skillID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
|
||||
return
|
||||
}
|
||||
var req services.InstallHubSkillRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.ValidationError(c, err)
|
||||
return
|
||||
}
|
||||
item, err := h.service.InstallHubSkill(userID.(int), userRole.(string), skillID, req.InstanceID)
|
||||
if err != nil {
|
||||
utils.HandleHubError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "Skill installed to instance successfully", item)
|
||||
}
|
||||
|
||||
func (h *SkillHubHandler) ListAdminSkills(c *gin.Context) {
|
||||
items, err := h.service.ListAllHubSkillsAdmin()
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "Admin skill hub items retrieved successfully", items)
|
||||
}
|
||||
|
||||
func parseIntDefault(raw string, fallback int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -94,6 +94,7 @@ func GatewayAuth(instanceRepo repository.InstanceRepository, bindingRepos ...rep
|
||||
|
||||
c.Set("userID", instance.UserID)
|
||||
c.Set("instanceID", instance.ID)
|
||||
c.Set("instanceType", strings.TrimSpace(instance.Type))
|
||||
c.Set("instanceMode", gatewayInstanceMode(instance.InstanceMode, instance.RuntimeType))
|
||||
c.Set("runtimeType", strings.TrimSpace(instance.RuntimeType))
|
||||
if bindingRepo != nil {
|
||||
|
||||
@@ -11,6 +11,9 @@ type Skill struct {
|
||||
CurrentVersionID *int `db:"current_version_id" json:"current_version_id,omitempty"`
|
||||
SourceType string `db:"source_type" json:"source_type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Visibility string `db:"visibility" json:"visibility"`
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
PublishedBy *int `db:"published_by" json:"published_by,omitempty"`
|
||||
RiskLevel string `db:"risk_level" json:"risk_level"`
|
||||
LastScannedAt *time.Time `db:"last_scanned_at" json:"last_scanned_at,omitempty"`
|
||||
LastScanResultID *int `db:"last_scan_result_id" json:"last_scan_result_id,omitempty"`
|
||||
@@ -58,6 +61,7 @@ type InstanceSkill struct {
|
||||
SkillVersionID *int `db:"skill_version_id" json:"skill_version_id,omitempty"`
|
||||
SourceType string `db:"source_type" json:"source_type"`
|
||||
InstallPath *string `db:"install_path" json:"install_path,omitempty"`
|
||||
WorkspaceDir *string `db:"workspace_dir" json:"workspace_dir,omitempty"`
|
||||
ObservedHash *string `db:"observed_hash" json:"observed_hash,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at,omitempty"`
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SkillHubTag struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
TagKey string `db:"tag_key" json:"tag_key"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
AdminOnly bool `db:"admin_only" json:"admin_only"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (t SkillHubTag) TableName() string { return "skill_hub_tags" }
|
||||
|
||||
type SkillHubTagAssignment struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
SkillID int `db:"skill_id" json:"skill_id"`
|
||||
TagID int `db:"tag_id" json:"tag_id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (a SkillHubTagAssignment) TableName() string { return "skill_hub_tag_assignments" }
|
||||
@@ -0,0 +1,26 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SkillPackageMaterializeJob struct {
|
||||
ID int `db:"id,primarykey,autoincrement" json:"id"`
|
||||
InstanceID int `db:"instance_id" json:"instance_id"`
|
||||
SkillID int `db:"skill_id" json:"skill_id"`
|
||||
BlobID int `db:"blob_id" json:"blob_id"`
|
||||
WorkspaceDir string `db:"workspace_dir" json:"workspace_dir"`
|
||||
ContentHash string `db:"content_hash" json:"content_hash"`
|
||||
Status string `db:"status" json:"status"`
|
||||
AttemptCount int `db:"attempt_count" json:"attempt_count"`
|
||||
MaxAttempts int `db:"max_attempts" json:"max_attempts"`
|
||||
LastError *string `db:"last_error" json:"last_error,omitempty"`
|
||||
IdempotencyKey string `db:"idempotency_key" json:"idempotency_key"`
|
||||
TriggerSource string `db:"trigger_source" json:"trigger_source"`
|
||||
StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (j SkillPackageMaterializeJob) TableName() string {
|
||||
return "skill_package_materialize_jobs"
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type AuditEventRepository interface {
|
||||
Create(event *models.AuditEvent) error
|
||||
ListByTraceID(traceID string) ([]models.AuditEvent, error)
|
||||
ListRecent(limit int) ([]models.AuditEvent, error)
|
||||
CountRecentByInstanceAndEventType(instanceID int, eventType string, since time.Time) (int, error)
|
||||
}
|
||||
|
||||
type auditEventRepository struct {
|
||||
@@ -92,3 +93,21 @@ func (r *auditEventRepository) ListRecent(limit int) ([]models.AuditEvent, error
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *auditEventRepository) CountRecentByInstanceAndEventType(instanceID int, eventType string, since time.Time) (int, error) {
|
||||
row, err := r.sess.SQL().QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM audit_events
|
||||
WHERE instance_id = ?
|
||||
AND event_type = ?
|
||||
AND created_at >= ?
|
||||
`, instanceID, eventType, since)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count audit events by instance and type: %w", err)
|
||||
}
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("failed to scan audit event count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
// ChatSessionRepository defines repository operations for chat sessions.
|
||||
type ChatSessionRepository interface {
|
||||
GetBySessionID(sessionID string) (*models.ChatSession, error)
|
||||
ListByInstanceID(instanceID int) ([]models.ChatSession, error)
|
||||
Save(session *models.ChatSession) error
|
||||
}
|
||||
|
||||
@@ -60,6 +61,14 @@ func (r *chatSessionRepository) GetBySessionID(sessionID string) (*models.ChatSe
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *chatSessionRepository) ListByInstanceID(instanceID int) ([]models.ChatSession, error) {
|
||||
var items []models.ChatSession
|
||||
if err := r.sess.Collection("chat_sessions").Find(db.Cond{"instance_id": instanceID}).OrderBy("-last_activity_at").All(&items); err != nil {
|
||||
return nil, fmt.Errorf("failed to list chat sessions by instance id: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *chatSessionRepository) Save(session *models.ChatSession) error {
|
||||
now := time.Now()
|
||||
existing, err := r.GetBySessionID(session.SessionID)
|
||||
|
||||
@@ -9,12 +9,23 @@ import (
|
||||
"github.com/upper/db/v4"
|
||||
)
|
||||
|
||||
// InstanceSessionCostAggregate summarizes cost usage for one session on an instance.
|
||||
type InstanceSessionCostAggregate struct {
|
||||
SessionID string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
EstimatedCost float64
|
||||
Currency string
|
||||
}
|
||||
|
||||
// CostRecordRepository defines repository operations for token and money accounting.
|
||||
type CostRecordRepository interface {
|
||||
Create(record *models.CostRecord) error
|
||||
ListByTraceID(traceID string) ([]models.CostRecord, error)
|
||||
ListByUserID(userID, limit int) ([]models.CostRecord, error)
|
||||
ListRecent(limit int) ([]models.CostRecord, error)
|
||||
AggregateCostByInstanceSession(instanceID int, filter SessionUsageFilter) ([]InstanceSessionCostAggregate, error)
|
||||
}
|
||||
|
||||
type costRecordRepository struct {
|
||||
@@ -110,3 +121,50 @@ func (r *costRecordRepository) ListRecent(limit int) ([]models.CostRecord, error
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *costRecordRepository) AggregateCostByInstanceSession(instanceID int, filter SessionUsageFilter) ([]InstanceSessionCostAggregate, error) {
|
||||
query := `
|
||||
SELECT cr.session_id,
|
||||
COALESCE(SUM(cr.prompt_tokens), 0),
|
||||
COALESCE(SUM(cr.completion_tokens), 0),
|
||||
COALESCE(SUM(cr.total_tokens), 0),
|
||||
COALESCE(SUM(cr.estimated_cost), 0),
|
||||
COALESCE(MAX(cr.currency), 'USD')
|
||||
FROM cost_records cr
|
||||
INNER JOIN model_invocations mi
|
||||
ON mi.trace_id = cr.trace_id
|
||||
AND mi.instance_id = cr.instance_id
|
||||
AND mi.status != ?
|
||||
WHERE cr.instance_id = ?
|
||||
AND cr.session_id IS NOT NULL
|
||||
AND cr.session_id != ''`
|
||||
args := []interface{}{models.ModelInvocationStatusBlocked, instanceID}
|
||||
query, args = appendTimeFilter(query, args, filter, "mi.created_at")
|
||||
query += `
|
||||
GROUP BY cr.session_id`
|
||||
rows, err := r.sess.SQL().Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to aggregate cost records by instance session: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]InstanceSessionCostAggregate, 0)
|
||||
for rows.Next() {
|
||||
var item InstanceSessionCostAggregate
|
||||
if err := rows.Scan(
|
||||
&item.SessionID,
|
||||
&item.PromptTokens,
|
||||
&item.CompletionTokens,
|
||||
&item.TotalTokens,
|
||||
&item.EstimatedCost,
|
||||
&item.Currency,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan session cost aggregate: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to iterate session cost aggregates: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
@@ -16,6 +17,7 @@ type InstanceCommandRepository interface {
|
||||
GetByInstanceIdempotencyKey(instanceID int, idempotencyKey string) (*models.InstanceCommand, error)
|
||||
GetNextPendingByInstance(instanceID int) (*models.InstanceCommand, error)
|
||||
ListByInstanceID(instanceID int, limit int) ([]models.InstanceCommand, error)
|
||||
FindLatestFailedCollectSkillPackage(skillExternalID string) (*models.InstanceCommand, error)
|
||||
}
|
||||
|
||||
type instanceCommandRepository struct {
|
||||
@@ -95,3 +97,22 @@ func (r *instanceCommandRepository) ListByInstanceID(instanceID int, limit int)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *instanceCommandRepository) FindLatestFailedCollectSkillPackage(skillExternalID string) (*models.InstanceCommand, error) {
|
||||
skillExternalID = strings.TrimSpace(skillExternalID)
|
||||
if skillExternalID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pattern := fmt.Sprintf("%%\"skill_id\":\"%s\"%%", skillExternalID)
|
||||
var item models.InstanceCommand
|
||||
if err := r.sess.Collection("instance_commands").Find(db.Cond{
|
||||
"command_type": "collect_skill_package",
|
||||
"status": "failed",
|
||||
}).And("payload_json LIKE ?", pattern).OrderBy("-finished_at", "-id").One(&item); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find failed collect skill package command: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@ import (
|
||||
"github.com/upper/db/v4"
|
||||
)
|
||||
|
||||
// InstanceSessionTokenAggregate summarizes token usage for one session on an instance.
|
||||
type InstanceSessionTokenAggregate struct {
|
||||
SessionID string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
InvocationCount int
|
||||
FirstSeenAt time.Time
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
// ModelInvocationRepository defines repository operations for governed model calls.
|
||||
type ModelInvocationRepository interface {
|
||||
Create(invocation *models.ModelInvocation) error
|
||||
@@ -18,6 +29,9 @@ type ModelInvocationRepository interface {
|
||||
ListBySessionID(sessionID string, limit int) ([]models.ModelInvocation, error)
|
||||
ListByUserID(userID, limit int) ([]models.ModelInvocation, error)
|
||||
ListRecent(limit int) ([]models.ModelInvocation, error)
|
||||
AggregateByInstanceSession(instanceID int, filter SessionUsageFilter) ([]InstanceSessionTokenAggregate, error)
|
||||
ListRecentByInstanceSession(instanceID int, sessionID string, limit int, filter SessionUsageFilter) ([]models.ModelInvocation, error)
|
||||
CountDistinctSessionsByInstance(instanceID int, filter SessionUsageFilter) (int, error)
|
||||
}
|
||||
|
||||
type modelInvocationRepository struct {
|
||||
@@ -147,6 +161,139 @@ func (r *modelInvocationRepository) ListRecent(limit int) ([]models.ModelInvocat
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *modelInvocationRepository) AggregateByInstanceSession(instanceID int, filter SessionUsageFilter) ([]InstanceSessionTokenAggregate, error) {
|
||||
query := `
|
||||
SELECT session_id,
|
||||
COALESCE(SUM(prompt_tokens), 0),
|
||||
COALESCE(SUM(completion_tokens), 0),
|
||||
COALESCE(SUM(total_tokens), 0),
|
||||
COUNT(*),
|
||||
MIN(created_at),
|
||||
MAX(created_at)
|
||||
FROM model_invocations
|
||||
WHERE instance_id = ?
|
||||
AND session_id IS NOT NULL
|
||||
AND session_id != ''
|
||||
AND status != ?`
|
||||
args := []interface{}{instanceID, models.ModelInvocationStatusBlocked}
|
||||
query, args = appendTimeFilter(query, args, filter, "created_at")
|
||||
query += `
|
||||
GROUP BY session_id`
|
||||
rows, err := r.sess.SQL().Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to aggregate model invocations by instance session: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]InstanceSessionTokenAggregate, 0)
|
||||
for rows.Next() {
|
||||
var item InstanceSessionTokenAggregate
|
||||
if err := rows.Scan(
|
||||
&item.SessionID,
|
||||
&item.PromptTokens,
|
||||
&item.CompletionTokens,
|
||||
&item.TotalTokens,
|
||||
&item.InvocationCount,
|
||||
&item.FirstSeenAt,
|
||||
&item.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan session token aggregate: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to iterate session token aggregates: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *modelInvocationRepository) ListRecentByInstanceSession(instanceID int, sessionID string, limit int, filter SessionUsageFilter) ([]models.ModelInvocation, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
query := `
|
||||
SELECT id, trace_id, session_id, request_id, user_id, instance_id, instance_mode, runtime_type, gateway_id, runtime_pod_id, model_id, provider_type, requested_model, actual_provider_model, traffic_class, request_payload, response_payload, prompt_tokens, completion_tokens, total_tokens, cached_tokens, reasoning_tokens, latency_ms, is_streaming, status, error_message, created_at, completed_at
|
||||
FROM model_invocations
|
||||
WHERE instance_id = ?
|
||||
AND session_id = ?
|
||||
AND status != ?`
|
||||
args := []interface{}{instanceID, sessionID, models.ModelInvocationStatusBlocked}
|
||||
query, args = appendTimeFilter(query, args, filter, "created_at")
|
||||
query += `
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := r.sess.SQL().Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list model invocations by instance session: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]models.ModelInvocation, 0, limit)
|
||||
for rows.Next() {
|
||||
var item models.ModelInvocation
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.TraceID,
|
||||
&item.SessionID,
|
||||
&item.RequestID,
|
||||
&item.UserID,
|
||||
&item.InstanceID,
|
||||
&item.InstanceMode,
|
||||
&item.RuntimeType,
|
||||
&item.GatewayID,
|
||||
&item.RuntimePodID,
|
||||
&item.ModelID,
|
||||
&item.ProviderType,
|
||||
&item.RequestedModel,
|
||||
&item.ActualProviderModel,
|
||||
&item.TrafficClass,
|
||||
&item.RequestPayload,
|
||||
&item.ResponsePayload,
|
||||
&item.PromptTokens,
|
||||
&item.CompletionTokens,
|
||||
&item.TotalTokens,
|
||||
&item.CachedTokens,
|
||||
&item.ReasoningTokens,
|
||||
&item.LatencyMs,
|
||||
&item.IsStreaming,
|
||||
&item.Status,
|
||||
&item.ErrorMessage,
|
||||
&item.CreatedAt,
|
||||
&item.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan model invocation by instance session: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to iterate model invocations by instance session: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *modelInvocationRepository) CountDistinctSessionsByInstance(instanceID int, filter SessionUsageFilter) (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT session_id)
|
||||
FROM model_invocations
|
||||
WHERE instance_id = ?
|
||||
AND session_id IS NOT NULL
|
||||
AND session_id != ''
|
||||
AND status != ?`
|
||||
args := []interface{}{instanceID, models.ModelInvocationStatusBlocked}
|
||||
query, args = appendTimeFilter(query, args, filter, "created_at")
|
||||
row, err := r.sess.SQL().QueryRow(query, args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count distinct sessions by instance: %w", err)
|
||||
}
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("failed to scan distinct session count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func isDuplicateIndexError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionUsageFilter optionally bounds session usage aggregates by timestamp.
|
||||
type SessionUsageFilter struct {
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
}
|
||||
|
||||
func appendTimeFilter(query string, args []interface{}, filter SessionUsageFilter, column string) (string, []interface{}) {
|
||||
if filter.Since != nil {
|
||||
query += fmt.Sprintf(" AND %s >= ?", column)
|
||||
args = append(args, *filter.Since)
|
||||
}
|
||||
if filter.Until != nil {
|
||||
query += fmt.Sprintf(" AND %s < ?", column)
|
||||
args = append(args, *filter.Until)
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAppendTimeFilterAddsSinceAndUntilClauses(t *testing.T) {
|
||||
since := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
until := time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
|
||||
query := "SELECT 1 FROM model_invocations WHERE instance_id = ?"
|
||||
args := []interface{}{9}
|
||||
|
||||
query, args = appendTimeFilter(query, args, SessionUsageFilter{
|
||||
Since: &since,
|
||||
Until: &until,
|
||||
}, "created_at")
|
||||
|
||||
if !strings.Contains(query, "created_at >= ?") || !strings.Contains(query, "created_at < ?") {
|
||||
t.Fatalf("expected created_at bounds in query, got %q", query)
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("expected 3 args, got %d (%v)", len(args), args)
|
||||
}
|
||||
if args[1] != since || args[2] != until {
|
||||
t.Fatalf("unexpected bound args: %+v", args[1:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendTimeFilterEmptyFilterLeavesQueryUnchanged(t *testing.T) {
|
||||
query := "SELECT 1 FROM cost_records WHERE instance_id = ?"
|
||||
args := []interface{}{9}
|
||||
|
||||
updatedQuery, updatedArgs := appendTimeFilter(query, args, SessionUsageFilter{}, "recorded_at")
|
||||
if updatedQuery != query || len(updatedArgs) != 1 {
|
||||
t.Fatalf("expected unchanged query/args, got query=%q args=%v", updatedQuery, updatedArgs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
|
||||
"github.com/upper/db/v4"
|
||||
)
|
||||
|
||||
type SkillPackageMaterializeBackfillCandidate struct {
|
||||
InstanceID int
|
||||
SkillID int
|
||||
BlobID int
|
||||
WorkspaceDir string
|
||||
ContentHash string
|
||||
}
|
||||
|
||||
type SkillPackageMaterializeJobRepository interface {
|
||||
Create(job *models.SkillPackageMaterializeJob) error
|
||||
GetByID(id int) (*models.SkillPackageMaterializeJob, error)
|
||||
GetByIdempotencyKey(key string) (*models.SkillPackageMaterializeJob, error)
|
||||
ClaimNextPending(ctx context.Context, limit int) ([]models.SkillPackageMaterializeJob, error)
|
||||
MarkSucceeded(id int) error
|
||||
MarkFailed(id int, errMsg string, retryable bool) error
|
||||
MarkRunning(id int) error
|
||||
ReleaseToPending(id int) error
|
||||
ResetForRetry(skillID int) error
|
||||
RequeueExisting(id, blobID int, contentHash, workspaceDir string) error
|
||||
FindLatestBySkillID(skillID int) (*models.SkillPackageMaterializeJob, error)
|
||||
CountPendingByInstance(instanceID int) (int, error)
|
||||
ListBackfillCandidates(limit int) ([]SkillPackageMaterializeBackfillCandidate, error)
|
||||
}
|
||||
|
||||
type skillPackageMaterializeJobRepository struct {
|
||||
sess db.Session
|
||||
}
|
||||
|
||||
func NewSkillPackageMaterializeJobRepository(sess db.Session) SkillPackageMaterializeJobRepository {
|
||||
return &skillPackageMaterializeJobRepository{sess: sess}
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) Create(job *models.SkillPackageMaterializeJob) error {
|
||||
existing, err := r.GetByIdempotencyKey(job.IdempotencyKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
*job = *existing
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(job.Status) == "" {
|
||||
job.Status = "pending"
|
||||
}
|
||||
if job.MaxAttempts <= 0 {
|
||||
job.MaxAttempts = 5
|
||||
}
|
||||
if strings.TrimSpace(job.TriggerSource) == "" {
|
||||
job.TriggerSource = "sync"
|
||||
}
|
||||
ensureTimestamps(&job.CreatedAt, &job.UpdatedAt)
|
||||
res, err := r.sess.Collection("skill_package_materialize_jobs").Insert(job)
|
||||
if err != nil {
|
||||
if isDuplicateEntryError(err) {
|
||||
existing, findErr := r.GetByIdempotencyKey(job.IdempotencyKey)
|
||||
if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
if existing != nil {
|
||||
*job = *existing
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to create skill package materialize job: %w", err)
|
||||
}
|
||||
if id, ok := res.ID().(int64); ok {
|
||||
job.ID = int(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) GetByID(id int) (*models.SkillPackageMaterializeJob, error) {
|
||||
var item models.SkillPackageMaterializeJob
|
||||
if err := r.sess.Collection("skill_package_materialize_jobs").Find(db.Cond{"id": id}).One(&item); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get skill package materialize job: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) GetByIdempotencyKey(key string) (*models.SkillPackageMaterializeJob, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var item models.SkillPackageMaterializeJob
|
||||
if err := r.sess.Collection("skill_package_materialize_jobs").Find(db.Cond{"idempotency_key": key}).One(&item); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get skill package materialize job by idempotency key: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) ClaimNextPending(ctx context.Context, limit int) ([]models.SkillPackageMaterializeJob, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
var ids []int
|
||||
iter := r.sess.SQL().IteratorContext(ctx, `
|
||||
SELECT id FROM skill_package_materialize_jobs
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?`, limit)
|
||||
for iter.Next() {
|
||||
var id int
|
||||
if err := iter.Scan(&id); err != nil {
|
||||
iter.Close()
|
||||
return nil, fmt.Errorf("failed to scan pending materialize job id: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := iter.Err(); err != nil {
|
||||
iter.Close()
|
||||
return nil, fmt.Errorf("failed to list pending materialize jobs: %w", err)
|
||||
}
|
||||
iter.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
claimed := make([]models.SkillPackageMaterializeJob, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
res, err := r.sess.SQL().ExecContext(ctx, `
|
||||
UPDATE skill_package_materialize_jobs
|
||||
SET status = 'running',
|
||||
attempt_count = attempt_count + 1,
|
||||
started_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND status = 'pending'`, now, now, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to claim materialize job %d: %w", id, err)
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read claim rows affected for job %d: %w", id, err)
|
||||
}
|
||||
if affected == 0 {
|
||||
continue
|
||||
}
|
||||
item, err := r.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item != nil {
|
||||
claimed = append(claimed, *item)
|
||||
}
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) MarkSucceeded(id int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.sess.SQL().Exec(`
|
||||
UPDATE skill_package_materialize_jobs
|
||||
SET status = 'succeeded',
|
||||
finished_at = ?,
|
||||
last_error = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?`, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark materialize job succeeded: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) MarkRunning(id int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.sess.SQL().Exec(`
|
||||
UPDATE skill_package_materialize_jobs
|
||||
SET status = 'running',
|
||||
attempt_count = attempt_count + 1,
|
||||
started_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND status = 'pending'`, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mark materialize job running: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) MarkFailed(id int, errMsg string, retryable bool) error {
|
||||
job, err := r.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job == nil {
|
||||
return fmt.Errorf("materialize job not found")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
status := "failed"
|
||||
if retryable && job.AttemptCount < job.MaxAttempts {
|
||||
status = "pending"
|
||||
}
|
||||
trimmed := strings.TrimSpace(errMsg)
|
||||
var lastError *string
|
||||
if trimmed != "" {
|
||||
lastError = &trimmed
|
||||
}
|
||||
update := map[string]interface{}{
|
||||
"status": status,
|
||||
"last_error": lastError,
|
||||
"updated_at": now,
|
||||
}
|
||||
if status == "failed" {
|
||||
update["finished_at"] = now
|
||||
}
|
||||
if err := r.sess.Collection("skill_package_materialize_jobs").Find(db.Cond{"id": id}).Update(update); err != nil {
|
||||
return fmt.Errorf("failed to mark materialize job failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) ReleaseToPending(id int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.sess.SQL().Exec(`
|
||||
UPDATE skill_package_materialize_jobs
|
||||
SET status = 'pending',
|
||||
started_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND status = 'running'`, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release materialize job to pending: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) ResetForRetry(skillID int) error {
|
||||
job, err := r.FindLatestBySkillID(skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job == nil {
|
||||
return fmt.Errorf("materialize job not found")
|
||||
}
|
||||
return r.RequeueExisting(job.ID, job.BlobID, job.ContentHash, job.WorkspaceDir)
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) RequeueExisting(id, blobID int, contentHash, workspaceDir string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := r.sess.SQL().Exec(`
|
||||
UPDATE skill_package_materialize_jobs
|
||||
SET status = 'pending',
|
||||
last_error = NULL,
|
||||
finished_at = NULL,
|
||||
started_at = NULL,
|
||||
blob_id = ?,
|
||||
content_hash = ?,
|
||||
workspace_dir = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`, blobID, strings.TrimSpace(contentHash), sanitizeMaterializeWorkspaceDir(workspaceDir), now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to requeue materialize job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeMaterializeWorkspaceDir(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) FindLatestBySkillID(skillID int) (*models.SkillPackageMaterializeJob, error) {
|
||||
var item models.SkillPackageMaterializeJob
|
||||
if err := r.sess.Collection("skill_package_materialize_jobs").Find(db.Cond{"skill_id": skillID}).OrderBy("-created_at", "-id").One(&item); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find latest materialize job: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) CountPendingByInstance(instanceID int) (int, error) {
|
||||
row, err := r.sess.SQL().QueryRow(`
|
||||
SELECT COUNT(*) FROM skill_package_materialize_jobs
|
||||
WHERE instance_id = ? AND status IN ('pending', 'running')`, instanceID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count pending materialize jobs: %w", err)
|
||||
}
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("failed to scan pending materialize job count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *skillPackageMaterializeJobRepository) ListBackfillCandidates(limit int) ([]SkillPackageMaterializeBackfillCandidate, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
iter := r.sess.SQL().Iterator(`
|
||||
SELECT isk.instance_id, isk.skill_id, sv.blob_id, isk.workspace_dir, sb.content_hash
|
||||
FROM instance_skills isk
|
||||
JOIN instances i ON i.id = isk.instance_id
|
||||
JOIN skills s ON s.id = isk.skill_id
|
||||
JOIN skill_versions sv ON sv.id = s.current_version_id
|
||||
JOIN skill_blobs sb ON sb.id = sv.blob_id
|
||||
WHERE isk.status = 'active'
|
||||
AND (LOWER(TRIM(i.instance_mode)) = 'lite' OR LOWER(TRIM(i.runtime_type)) = 'gateway')
|
||||
AND TRIM(sb.object_key) = ''
|
||||
AND isk.workspace_dir IS NOT NULL
|
||||
AND TRIM(isk.workspace_dir) <> ''
|
||||
ORDER BY isk.updated_at ASC
|
||||
LIMIT ?`, limit)
|
||||
defer iter.Close()
|
||||
|
||||
result := make([]SkillPackageMaterializeBackfillCandidate, 0)
|
||||
for iter.Next() {
|
||||
var item SkillPackageMaterializeBackfillCandidate
|
||||
if err := iter.Scan(&item.InstanceID, &item.SkillID, &item.BlobID, &item.WorkspaceDir, &item.ContentHash); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan backfill candidate: %w", err)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
if err := iter.Err(); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("failed to list backfill candidates: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -27,7 +27,9 @@ type SkillRepository interface {
|
||||
GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error)
|
||||
GetLatestVersionBySkillID(skillID int) (*models.SkillVersion, error)
|
||||
CreateVersion(version *models.SkillVersion) error
|
||||
UpdateVersion(version *models.SkillVersion) error
|
||||
ListInstanceSkills(instanceID int) ([]models.InstanceSkill, error)
|
||||
ListActiveInstanceSkillsBySkillID(skillID int) ([]models.InstanceSkill, error)
|
||||
GetInstanceSkill(instanceID, skillID int) (*models.InstanceSkill, error)
|
||||
UpsertInstanceSkill(item *models.InstanceSkill) error
|
||||
MarkInstanceSkillRemoved(instanceID int, skillID int, observedAt time.Time) error
|
||||
@@ -39,6 +41,12 @@ type SkillRepository interface {
|
||||
ListScanResultsByBlobID(blobID int) ([]models.SkillScanResult, error)
|
||||
GetLatestScanResultByBlobID(blobID int) (*models.SkillScanResult, error)
|
||||
GetLatestScanResultBySkillID(skillID int) (*models.SkillScanResult, error)
|
||||
ListHubTags(includeAdminOnly bool) ([]models.SkillHubTag, error)
|
||||
GetHubTagByID(id int) (*models.SkillHubTag, error)
|
||||
ListHubTagsBySkillID(skillID int) ([]models.SkillHubTag, error)
|
||||
ReplaceSkillTagAssignments(skillID int, tagIDs []int) error
|
||||
ListPublicHubSkills() ([]models.Skill, error)
|
||||
ListSkillsForHubAdmin() ([]models.Skill, error)
|
||||
}
|
||||
|
||||
type skillRepository struct{ sess db.Session }
|
||||
@@ -74,7 +82,7 @@ func (r *skillRepository) GetSkillByID(id int) (*models.Skill, error) {
|
||||
|
||||
func (r *skillRepository) GetSkillByUserKey(userID int, skillKey string) (*models.Skill, error) {
|
||||
var item models.Skill
|
||||
if err := r.sess.Collection("skills").Find(db.Cond{"user_id": userID, "skill_key": skillKey}).One(&item); err != nil {
|
||||
if err := r.sess.Collection("skills").Find(db.Cond{"user_id": userID, "skill_key": skillKey, "status": "active"}).One(&item); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -84,6 +92,9 @@ func (r *skillRepository) GetSkillByUserKey(userID int, skillKey string) (*model
|
||||
}
|
||||
|
||||
func (r *skillRepository) CreateSkill(skill *models.Skill) error {
|
||||
if strings.TrimSpace(skill.Visibility) == "" {
|
||||
skill.Visibility = "private"
|
||||
}
|
||||
ensureTimestamps(&skill.CreatedAt, &skill.UpdatedAt)
|
||||
res, err := r.sess.Collection("skills").Insert(skill)
|
||||
if err != nil {
|
||||
@@ -219,6 +230,16 @@ func (r *skillRepository) CreateVersion(version *models.SkillVersion) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) UpdateVersion(version *models.SkillVersion) error {
|
||||
if version.UpdatedAt.IsZero() {
|
||||
version.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if err := r.sess.Collection("skill_versions").Find(db.Cond{"id": version.ID}).Update(version); err != nil {
|
||||
return fmt.Errorf("failed to update skill version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) ListInstanceSkills(instanceID int) ([]models.InstanceSkill, error) {
|
||||
var items []models.InstanceSkill
|
||||
if err := r.sess.Collection("instance_skills").Find(db.Cond{"instance_id": instanceID}).OrderBy("-updated_at", "-id").All(&items); err != nil {
|
||||
@@ -227,6 +248,17 @@ func (r *skillRepository) ListInstanceSkills(instanceID int) ([]models.InstanceS
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) ListActiveInstanceSkillsBySkillID(skillID int) ([]models.InstanceSkill, error) {
|
||||
var items []models.InstanceSkill
|
||||
if err := r.sess.Collection("instance_skills").Find(db.Cond{
|
||||
"skill_id": skillID,
|
||||
"status NOT IN": []string{"removed", "missing"},
|
||||
}).OrderBy("-updated_at", "-id").All(&items); err != nil {
|
||||
return nil, fmt.Errorf("failed to list active instance skills by skill id: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) GetInstanceSkill(instanceID, skillID int) (*models.InstanceSkill, error) {
|
||||
var item models.InstanceSkill
|
||||
if err := r.sess.Collection("instance_skills").Find(db.Cond{"instance_id": instanceID, "skill_id": skillID}).One(&item); err != nil {
|
||||
@@ -262,6 +294,9 @@ func (r *skillRepository) UpsertInstanceSkill(item *models.InstanceSkill) error
|
||||
if item.UpdatedAt.IsZero() {
|
||||
item.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if existing.SourceType == "injected_by_clawmanager" && item.SourceType == "discovered_in_instance" {
|
||||
item.SourceType = existing.SourceType
|
||||
}
|
||||
if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": existing.ID}).Update(item); err != nil {
|
||||
return fmt.Errorf("failed to update instance skill after duplicate insert: %w", err)
|
||||
}
|
||||
@@ -277,6 +312,9 @@ func (r *skillRepository) UpsertInstanceSkill(item *models.InstanceSkill) error
|
||||
if item.UpdatedAt.IsZero() {
|
||||
item.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
if existing.SourceType == "injected_by_clawmanager" && item.SourceType == "discovered_in_instance" {
|
||||
item.SourceType = existing.SourceType
|
||||
}
|
||||
if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": existing.ID}).Update(item); err != nil {
|
||||
return fmt.Errorf("failed to update instance skill: %w", err)
|
||||
}
|
||||
@@ -407,11 +445,9 @@ func workspaceDeleteTargetsSkillKey(deletedPath string, skillKey string) bool {
|
||||
if len(segments) <= 2 {
|
||||
return true
|
||||
}
|
||||
for i, segment := range segments {
|
||||
if segment == "skills" && i+1 < len(segments) && segments[i+1] == key {
|
||||
return true
|
||||
}
|
||||
if segment == ".openclaw" || segment == "openclaw" {
|
||||
for _, segment := range segments {
|
||||
switch segment {
|
||||
case "skills", ".hermes", "hermes", ".openclaw", "openclaw":
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -419,7 +455,8 @@ func workspaceDeleteTargetsSkillKey(deletedPath string, skillKey string) bool {
|
||||
}
|
||||
|
||||
func isRemovedInstanceSkillRecord(item models.InstanceSkill) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(item.Status), "removed") || item.RemovedAt != nil
|
||||
status := strings.ToLower(strings.TrimSpace(item.Status))
|
||||
return status == "removed" || status == "missing"
|
||||
}
|
||||
|
||||
func (r *skillRepository) MarkMissingInstanceSkills(instanceID int, activeSkillIDs []int, observedAt time.Time) error {
|
||||
@@ -432,11 +469,14 @@ func (r *skillRepository) MarkMissingInstanceSkills(instanceID int, activeSkillI
|
||||
return fmt.Errorf("failed to list stale instance skills: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
item.Status = "removed"
|
||||
if strings.EqualFold(strings.TrimSpace(item.Status), "removed") {
|
||||
continue
|
||||
}
|
||||
item.Status = "missing"
|
||||
item.RemovedAt = &observedAt
|
||||
item.UpdatedAt = observedAt
|
||||
if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": item.ID}).Update(item); err != nil {
|
||||
return fmt.Errorf("failed to mark instance skill removed: %w", err)
|
||||
return fmt.Errorf("failed to mark instance skill missing: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -491,3 +531,79 @@ func (r *skillRepository) GetLatestScanResultBySkillID(skillID int) (*models.Ski
|
||||
}
|
||||
return r.GetScanResultByID(*skill.LastScanResultID)
|
||||
}
|
||||
|
||||
func (r *skillRepository) ListHubTags(includeAdminOnly bool) ([]models.SkillHubTag, error) {
|
||||
var items []models.SkillHubTag
|
||||
query := r.sess.Collection("skill_hub_tags").Find()
|
||||
if !includeAdminOnly {
|
||||
query = query.And(db.Cond{"admin_only": false})
|
||||
}
|
||||
if err := query.OrderBy("sort_order", "id").All(&items); err != nil {
|
||||
return nil, fmt.Errorf("failed to list skill hub tags: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) GetHubTagByID(id int) (*models.SkillHubTag, error) {
|
||||
var item models.SkillHubTag
|
||||
if err := r.sess.Collection("skill_hub_tags").Find(db.Cond{"id": id}).One(&item); err != nil {
|
||||
if err == db.ErrNoMoreRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get skill hub tag: %w", err)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) ListHubTagsBySkillID(skillID int) ([]models.SkillHubTag, error) {
|
||||
var assignments []models.SkillHubTagAssignment
|
||||
if err := r.sess.Collection("skill_hub_tag_assignments").Find(db.Cond{"skill_id": skillID}).All(&assignments); err != nil {
|
||||
return nil, fmt.Errorf("failed to list skill hub tag assignments: %w", err)
|
||||
}
|
||||
if len(assignments) == 0 {
|
||||
return []models.SkillHubTag{}, nil
|
||||
}
|
||||
tagIDs := make([]interface{}, 0, len(assignments))
|
||||
for _, item := range assignments {
|
||||
tagIDs = append(tagIDs, item.TagID)
|
||||
}
|
||||
var tags []models.SkillHubTag
|
||||
if err := r.sess.Collection("skill_hub_tags").Find(db.Cond{"id IN": tagIDs}).OrderBy("sort_order", "id").All(&tags); err != nil {
|
||||
return nil, fmt.Errorf("failed to list skill hub tags by skill id: %w", err)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) ReplaceSkillTagAssignments(skillID int, tagIDs []int) error {
|
||||
if err := r.sess.Collection("skill_hub_tag_assignments").Find(db.Cond{"skill_id": skillID}).Delete(); err != nil {
|
||||
return fmt.Errorf("failed to clear skill hub tag assignments: %w", err)
|
||||
}
|
||||
for _, tagID := range tagIDs {
|
||||
assignment := &models.SkillHubTagAssignment{SkillID: skillID, TagID: tagID}
|
||||
ensureTimestamps(&assignment.CreatedAt, nil)
|
||||
if _, err := r.sess.Collection("skill_hub_tag_assignments").Insert(assignment); err != nil {
|
||||
return fmt.Errorf("failed to create skill hub tag assignment: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) ListPublicHubSkills() ([]models.Skill, error) {
|
||||
var items []models.Skill
|
||||
if err := r.sess.Collection("skills").Find(db.Cond{
|
||||
"visibility": "public",
|
||||
"source_type": "uploaded",
|
||||
"status": "active",
|
||||
}).OrderBy("-published_at", "-updated_at", "-id").All(&items); err != nil {
|
||||
return nil, fmt.Errorf("failed to list public hub skills: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *skillRepository) ListSkillsForHubAdmin() ([]models.Skill, error) {
|
||||
var items []models.Skill
|
||||
if err := r.sess.Collection("skills").Find(db.Cond{"source_type": "uploaded"}).OrderBy("-updated_at", "-id").All(&items); err != nil {
|
||||
return nil, fmt.Errorf("failed to list skills for hub admin: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package repository
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWorkspaceDeleteTargetsSkillKeyHermesNestedPath(t *testing.T) {
|
||||
path := "home/.hermes/skills/productivity/my-skill"
|
||||
if !workspaceDeleteTargetsSkillKey(path, "my-skill") {
|
||||
t.Fatalf("expected nested hermes skill path to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDeleteTargetsSkillKeyOpenClawFlatPath(t *testing.T) {
|
||||
path := "home/.openclaw/workspace/skills/paper-ranker"
|
||||
if !workspaceDeleteTargetsSkillKey(path, "paper-ranker") {
|
||||
t.Fatalf("expected openclaw flat skill path to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDeleteTargetsSkillKeyRejectsMismatchedLeaf(t *testing.T) {
|
||||
path := "home/.hermes/skills/productivity/other-skill"
|
||||
if workspaceDeleteTargetsSkillKey(path, "my-skill") {
|
||||
t.Fatalf("expected mismatched leaf to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
)
|
||||
|
||||
type stubGovernanceInstanceRepo struct {
|
||||
instances []models.Instance
|
||||
}
|
||||
|
||||
func (s *stubGovernanceInstanceRepo) Create(*models.Instance) error { panic("not used") }
|
||||
func (s *stubGovernanceInstanceRepo) GetByID(int) (*models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) GetByAccessToken(string) (*models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) GetByAgentBootstrapToken(string) (*models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) GetAll(int, int) ([]models.Instance, error) { panic("not used") }
|
||||
func (s *stubGovernanceInstanceRepo) CountAll() (int, error) { panic("not used") }
|
||||
func (s *stubGovernanceInstanceRepo) GetByUserID(int, int, int) ([]models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) CountByUserID(int) (int, error) { panic("not used") }
|
||||
func (s *stubGovernanceInstanceRepo) CountActiveByMode(context.Context, string) (int, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) ExistsByUserIDAndName(int, string) (bool, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) GetAllRunning() ([]models.Instance, error) {
|
||||
return s.instances, nil
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) GetV2DesiredRunning(context.Context, int) ([]models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) GetV2Creating(context.Context, int) ([]models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) UpdateRuntimeState(context.Context, int, string, int, *string) error {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) SetWorkspacePath(context.Context, int, string) error {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) UpdateWorkspaceUsage(context.Context, int, int64) error {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubGovernanceInstanceRepo) Update(*models.Instance) error { panic("not used") }
|
||||
func (s *stubGovernanceInstanceRepo) Delete(int) error { panic("not used") }
|
||||
|
||||
type stubGovernanceRuntimeStatusRepo struct {
|
||||
external map[int]bool
|
||||
}
|
||||
|
||||
func (s *stubGovernanceRuntimeStatusRepo) GetByInstanceID(instanceID int) (*models.InstanceRuntimeStatus, error) {
|
||||
if s.external[instanceID] {
|
||||
raw := `{"llm_config_status":"external"}`
|
||||
return &models.InstanceRuntimeStatus{SystemInfoJSON: &raw}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubGovernanceRuntimeStatusRepo) Create(*models.InstanceRuntimeStatus) error { panic("not used") }
|
||||
func (s *stubGovernanceRuntimeStatusRepo) Update(*models.InstanceRuntimeStatus) error { panic("not used") }
|
||||
|
||||
func TestGetLLMGovernanceOverviewSummarizesManagedRuntimeInstances(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 10, LastSeenAt: now, FirstSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
auditRepo: &stubSessionUsageAuditRepo{},
|
||||
instanceRepo: &stubGovernanceInstanceRepo{
|
||||
instances: []models.Instance{
|
||||
{ID: 1, UserID: 9, Name: "oc-1", Type: "openclaw", Status: "running"},
|
||||
{ID: 2, UserID: 9, Name: "oc-2", Type: "openclaw", Status: "running"},
|
||||
{ID: 3, UserID: 9, Name: "ubuntu", Type: "ubuntu", Status: "running"},
|
||||
},
|
||||
},
|
||||
runtimeStatusRepo: &stubGovernanceRuntimeStatusRepo{
|
||||
external: map[int]bool{2: true},
|
||||
},
|
||||
}
|
||||
|
||||
overview, err := service.GetLLMGovernanceOverview()
|
||||
if err != nil {
|
||||
t.Fatalf("GetLLMGovernanceOverview failed: %v", err)
|
||||
}
|
||||
if overview.TotalManagedInstances != 2 {
|
||||
t.Fatalf("expected 2 managed instances, got %d", overview.TotalManagedInstances)
|
||||
}
|
||||
if overview.ExternalConfigCount != 1 {
|
||||
t.Fatalf("expected 1 external config instance, got %d", overview.ExternalConfigCount)
|
||||
}
|
||||
if len(overview.Items) != 2 {
|
||||
t.Fatalf("expected 2 overview items, got %d", len(overview.Items))
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
"clawreef/internal/utils"
|
||||
)
|
||||
|
||||
// AuditQuery contains query options for AI audit list views.
|
||||
@@ -160,11 +162,152 @@ type CostRecordView struct {
|
||||
RecordedAt time.Time `json:"recorded_at"`
|
||||
}
|
||||
|
||||
// InstanceSessionUsageQuery contains query options for instance session usage views.
|
||||
type InstanceSessionUsageQuery struct {
|
||||
Page int
|
||||
Limit int
|
||||
Search string
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
}
|
||||
|
||||
// InstanceSessionUsageSummary summarizes token usage across all sessions on an instance.
|
||||
type InstanceSessionUsageSummary struct {
|
||||
TotalPromptTokens int `json:"total_prompt_tokens"`
|
||||
TotalCompletionTokens int `json:"total_completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
TotalEstimatedCost float64 `json:"total_estimated_cost"`
|
||||
Currency string `json:"currency"`
|
||||
SessionCount int `json:"session_count"`
|
||||
}
|
||||
|
||||
// InstanceSessionUsageCompliance reports session attribution quality for an instance.
|
||||
type InstanceSessionUsageCompliance struct {
|
||||
FallbackSessionCount int `json:"fallback_session_count"`
|
||||
HasFallbackSessions bool `json:"has_fallback_sessions"`
|
||||
RecentFallbackAuditCount int `json:"recent_fallback_audit_count"`
|
||||
}
|
||||
|
||||
// InstanceSessionUsageItem is one session row for instance usage reporting.
|
||||
type InstanceSessionUsageItem struct {
|
||||
SessionID string `json:"session_id"`
|
||||
SessionKey string `json:"session_key"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
EstimatedCost float64 `json:"estimated_cost"`
|
||||
Currency string `json:"currency"`
|
||||
InvocationCount int `json:"invocation_count"`
|
||||
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
// InstanceSessionUsageResult is the paginated instance session usage response.
|
||||
type InstanceSessionUsageResult struct {
|
||||
Summary InstanceSessionUsageSummary `json:"summary"`
|
||||
Compliance InstanceSessionUsageCompliance `json:"compliance"`
|
||||
Items []InstanceSessionUsageItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// InstanceSessionTrace is a recent trace row within one session.
|
||||
type InstanceSessionTrace struct {
|
||||
TraceID string `json:"trace_id"`
|
||||
RequestedModel string `json:"requested_model"`
|
||||
Status string `json:"status"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// InstanceSessionUsageDetail is the drill-down payload for one session on an instance.
|
||||
type InstanceSessionUsageDetail struct {
|
||||
SessionID string `json:"session_id"`
|
||||
SessionKey string `json:"session_key"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
EstimatedCost float64 `json:"estimated_cost"`
|
||||
Currency string `json:"currency"`
|
||||
InvocationCount int `json:"invocation_count"`
|
||||
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
ModelBreakdown []CostBreakdownItem `json:"model_breakdown"`
|
||||
RecentTraces []InstanceSessionTrace `json:"recent_traces"`
|
||||
}
|
||||
|
||||
// InstanceLLMGovernanceStatus summarizes LLM governance health for one instance.
|
||||
type InstanceLLMGovernanceStatus struct {
|
||||
ConfigStatus string `json:"config_status"`
|
||||
SessionFallbackRate float64 `json:"session_fallback_rate"`
|
||||
RecentEgressBlockCount int `json:"recent_egress_block_count"`
|
||||
IsCompliant bool `json:"is_compliant"`
|
||||
}
|
||||
|
||||
// LLMGovernanceOverviewItem summarizes governance for one managed runtime instance.
|
||||
type LLMGovernanceOverviewItem struct {
|
||||
InstanceID int `json:"instance_id"`
|
||||
InstanceName string `json:"instance_name"`
|
||||
InstanceType string `json:"instance_type"`
|
||||
UserID int `json:"user_id"`
|
||||
ConfigStatus string `json:"config_status"`
|
||||
SessionFallbackRate float64 `json:"session_fallback_rate"`
|
||||
RecentEgressBlockCount int `json:"recent_egress_block_count"`
|
||||
IsCompliant bool `json:"is_compliant"`
|
||||
}
|
||||
|
||||
// LLMGovernanceOverview aggregates governance signals across managed runtime instances.
|
||||
type LLMGovernanceOverview struct {
|
||||
TotalManagedInstances int `json:"total_managed_instances"`
|
||||
NonCompliantCount int `json:"non_compliant_count"`
|
||||
ExternalConfigCount int `json:"external_config_count"`
|
||||
HighFallbackCount int `json:"high_fallback_count"`
|
||||
Items []LLMGovernanceOverviewItem `json:"items"`
|
||||
}
|
||||
|
||||
// InstanceSessionUsageOverviewQuery contains query options for admin session usage overview.
|
||||
type InstanceSessionUsageOverviewQuery struct {
|
||||
Page int
|
||||
Limit int
|
||||
Search string
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
}
|
||||
|
||||
// InstanceSessionUsageOverviewItem summarizes session usage for one managed runtime instance.
|
||||
type InstanceSessionUsageOverviewItem struct {
|
||||
InstanceID int `json:"instance_id"`
|
||||
InstanceName string `json:"instance_name"`
|
||||
InstanceType string `json:"instance_type"`
|
||||
UserID int `json:"user_id"`
|
||||
Summary InstanceSessionUsageSummary `json:"summary"`
|
||||
Compliance InstanceSessionUsageCompliance `json:"compliance"`
|
||||
}
|
||||
|
||||
// InstanceSessionUsageOverview aggregates session usage across managed runtime instances.
|
||||
type InstanceSessionUsageOverview struct {
|
||||
Summary InstanceSessionUsageSummary `json:"summary"`
|
||||
Items []InstanceSessionUsageOverviewItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// AIObservabilityService provides read APIs for audit and cost reporting.
|
||||
type AIObservabilityService interface {
|
||||
ListAuditItems(query AuditQuery) (*AuditListResult, error)
|
||||
GetTraceDetail(traceID string) (*AuditTraceDetail, error)
|
||||
GetCostOverview(query CostQuery) (*CostOverview, error)
|
||||
GetInstanceSessionUsage(instanceID int, query InstanceSessionUsageQuery) (*InstanceSessionUsageResult, error)
|
||||
GetInstanceSessionUsageDetail(instanceID int, sessionID string, filter repository.SessionUsageFilter) (*InstanceSessionUsageDetail, error)
|
||||
GetInstanceLLMGovernanceStatus(instanceID int, runtimeSystemInfo map[string]interface{}) (*InstanceLLMGovernanceStatus, error)
|
||||
GetLLMGovernanceOverview() (*LLMGovernanceOverview, error)
|
||||
GetAdminSessionUsageOverview(query InstanceSessionUsageOverviewQuery) (*InstanceSessionUsageOverview, error)
|
||||
}
|
||||
|
||||
type aiObservabilityService struct {
|
||||
@@ -173,9 +316,11 @@ type aiObservabilityService struct {
|
||||
costRepo repository.CostRecordRepository
|
||||
riskHitRepo repository.RiskHitRepository
|
||||
chatMessageRepo repository.ChatMessageRepository
|
||||
chatSessionRepo repository.ChatSessionRepository
|
||||
llmModelRepo repository.LLMModelRepository
|
||||
instanceRepo repository.InstanceRepository
|
||||
userRepo repository.UserRepository
|
||||
instanceRepo repository.InstanceRepository
|
||||
userRepo repository.UserRepository
|
||||
runtimeStatusRepo repository.InstanceRuntimeStatusRepository
|
||||
}
|
||||
|
||||
// NewAIObservabilityService creates a new observability reporting service.
|
||||
@@ -185,19 +330,23 @@ func NewAIObservabilityService(
|
||||
costRepo repository.CostRecordRepository,
|
||||
riskHitRepo repository.RiskHitRepository,
|
||||
chatMessageRepo repository.ChatMessageRepository,
|
||||
chatSessionRepo repository.ChatSessionRepository,
|
||||
llmModelRepo repository.LLMModelRepository,
|
||||
instanceRepo repository.InstanceRepository,
|
||||
userRepo repository.UserRepository,
|
||||
runtimeStatusRepo repository.InstanceRuntimeStatusRepository,
|
||||
) AIObservabilityService {
|
||||
return &aiObservabilityService{
|
||||
invocationRepo: invocationRepo,
|
||||
auditRepo: auditRepo,
|
||||
costRepo: costRepo,
|
||||
riskHitRepo: riskHitRepo,
|
||||
chatMessageRepo: chatMessageRepo,
|
||||
llmModelRepo: llmModelRepo,
|
||||
instanceRepo: instanceRepo,
|
||||
userRepo: userRepo,
|
||||
invocationRepo: invocationRepo,
|
||||
auditRepo: auditRepo,
|
||||
costRepo: costRepo,
|
||||
riskHitRepo: riskHitRepo,
|
||||
chatMessageRepo: chatMessageRepo,
|
||||
chatSessionRepo: chatSessionRepo,
|
||||
llmModelRepo: llmModelRepo,
|
||||
instanceRepo: instanceRepo,
|
||||
userRepo: userRepo,
|
||||
runtimeStatusRepo: runtimeStatusRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1847,3 +1996,474 @@ func valueOrCostTotalTokens(cost *models.CostRecord) int {
|
||||
func pointerToInt(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func (s *aiObservabilityService) GetInstanceSessionUsage(instanceID int, query InstanceSessionUsageQuery) (*InstanceSessionUsageResult, error) {
|
||||
page, limit := normalizePageLimit(query.Page, query.Limit, 20, 100)
|
||||
filter := repository.SessionUsageFilter{Since: query.Since, Until: query.Until}
|
||||
allItems, err := s.mergeInstanceSessionUsageItems(instanceID, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := filterSessionUsageItems(allItems, query.Search)
|
||||
|
||||
summary, compliance := summarizeSessionUsageItems(allItems, filter, instanceID, s.invocationRepo, s.auditRepo)
|
||||
|
||||
total := len(items)
|
||||
start := (page - 1) * limit
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
paged := make([]InstanceSessionUsageItem, 0, end-start)
|
||||
if start < end {
|
||||
paged = append(paged, items[start:end]...)
|
||||
}
|
||||
|
||||
return &InstanceSessionUsageResult{
|
||||
Summary: summary,
|
||||
Compliance: compliance,
|
||||
Items: paged,
|
||||
Total: total,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *aiObservabilityService) GetInstanceSessionUsageDetail(instanceID int, sessionID string, filter repository.SessionUsageFilter) (*InstanceSessionUsageDetail, error) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil, fmt.Errorf("session id is required")
|
||||
}
|
||||
|
||||
items, err := s.mergeInstanceSessionUsageItems(instanceID, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var matched *InstanceSessionUsageItem
|
||||
for index := range items {
|
||||
if items[index].SessionID == sessionID {
|
||||
matched = &items[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
return nil, fmt.Errorf("session usage not found")
|
||||
}
|
||||
|
||||
invocations, err := s.invocationRepo.ListRecentByInstanceSession(instanceID, sessionID, 20, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list recent invocations for session: %w", err)
|
||||
}
|
||||
|
||||
modelTotals := map[string]*CostBreakdownItem{}
|
||||
recentTraces := make([]InstanceSessionTrace, 0, len(invocations))
|
||||
for _, invocation := range invocations {
|
||||
modelRow := modelTotals[invocation.RequestedModel]
|
||||
if modelRow == nil {
|
||||
modelRow = &CostBreakdownItem{Label: invocation.RequestedModel}
|
||||
modelTotals[invocation.RequestedModel] = modelRow
|
||||
}
|
||||
modelRow.PromptTokens += invocation.PromptTokens
|
||||
modelRow.CompletionTokens += invocation.CompletionTokens
|
||||
modelRow.TotalTokens += invocation.TotalTokens
|
||||
if s.costRepo != nil {
|
||||
if costs, costErr := s.costRepo.ListByTraceID(invocation.TraceID); costErr == nil {
|
||||
for _, cost := range costs {
|
||||
modelRow.EstimatedCost += cost.EstimatedCost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentTraces = append(recentTraces, InstanceSessionTrace{
|
||||
TraceID: invocation.TraceID,
|
||||
RequestedModel: invocation.RequestedModel,
|
||||
Status: invocation.Status,
|
||||
PromptTokens: invocation.PromptTokens,
|
||||
CompletionTokens: invocation.CompletionTokens,
|
||||
TotalTokens: invocation.TotalTokens,
|
||||
CreatedAt: invocation.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &InstanceSessionUsageDetail{
|
||||
SessionID: matched.SessionID,
|
||||
SessionKey: matched.SessionKey,
|
||||
Title: matched.Title,
|
||||
PromptTokens: matched.PromptTokens,
|
||||
CompletionTokens: matched.CompletionTokens,
|
||||
TotalTokens: matched.TotalTokens,
|
||||
EstimatedCost: matched.EstimatedCost,
|
||||
Currency: matched.Currency,
|
||||
InvocationCount: matched.InvocationCount,
|
||||
FirstSeenAt: matched.FirstSeenAt,
|
||||
LastSeenAt: matched.LastSeenAt,
|
||||
ModelBreakdown: s.completeModelBreakdowns(modelTotals),
|
||||
RecentTraces: recentTraces,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *aiObservabilityService) GetInstanceLLMGovernanceStatus(instanceID int, runtimeSystemInfo map[string]interface{}) (*InstanceLLMGovernanceStatus, error) {
|
||||
items, err := s.mergeInstanceSessionUsageItems(instanceID, repository.SessionUsageFilter{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fallbackCount := 0
|
||||
for _, item := range items {
|
||||
if utils.IsTraceFallbackSessionID(item.SessionID) {
|
||||
fallbackCount++
|
||||
}
|
||||
}
|
||||
|
||||
fallbackRate := 0.0
|
||||
if len(items) > 0 {
|
||||
fallbackRate = float64(fallbackCount) / float64(len(items))
|
||||
}
|
||||
|
||||
configStatus := classifyLLMConfigStatusFromSystemInfo(runtimeSystemInfo)
|
||||
since := time.Now().UTC().Add(-24 * time.Hour)
|
||||
egressBlockCount := 0
|
||||
if s.auditRepo != nil {
|
||||
if count, err := s.auditRepo.CountRecentByInstanceAndEventType(instanceID, "egress.llm.blocked", since); err == nil {
|
||||
egressBlockCount = count
|
||||
}
|
||||
}
|
||||
|
||||
isCompliant := fallbackRate == 0 && configStatus != "external"
|
||||
if configStatus == "gateway" {
|
||||
isCompliant = fallbackRate == 0
|
||||
}
|
||||
|
||||
status := &InstanceLLMGovernanceStatus{
|
||||
ConfigStatus: configStatus,
|
||||
SessionFallbackRate: fallbackRate,
|
||||
RecentEgressBlockCount: egressBlockCount,
|
||||
IsCompliant: isCompliant,
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *aiObservabilityService) GetLLMGovernanceOverview() (*LLMGovernanceOverview, error) {
|
||||
if s.instanceRepo == nil {
|
||||
return &LLMGovernanceOverview{Items: []LLMGovernanceOverviewItem{}}, nil
|
||||
}
|
||||
|
||||
instances, err := s.instanceRepo.GetAllRunning()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list running instances: %w", err)
|
||||
}
|
||||
|
||||
overview := &LLMGovernanceOverview{
|
||||
Items: make([]LLMGovernanceOverviewItem, 0),
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if !supportsManagedRuntimeIntegration(instance.Type) {
|
||||
continue
|
||||
}
|
||||
systemInfo := decodeRuntimeSystemInfo(s.runtimeStatusRepo, instance.ID)
|
||||
status, err := s.GetInstanceLLMGovernanceStatus(instance.ID, systemInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
item := LLMGovernanceOverviewItem{
|
||||
InstanceID: instance.ID,
|
||||
InstanceName: instance.Name,
|
||||
InstanceType: instance.Type,
|
||||
UserID: instance.UserID,
|
||||
ConfigStatus: status.ConfigStatus,
|
||||
SessionFallbackRate: status.SessionFallbackRate,
|
||||
RecentEgressBlockCount: status.RecentEgressBlockCount,
|
||||
IsCompliant: status.IsCompliant,
|
||||
}
|
||||
overview.TotalManagedInstances++
|
||||
overview.Items = append(overview.Items, item)
|
||||
if !status.IsCompliant {
|
||||
overview.NonCompliantCount++
|
||||
}
|
||||
if status.ConfigStatus == "external" {
|
||||
overview.ExternalConfigCount++
|
||||
}
|
||||
if status.SessionFallbackRate > 0 {
|
||||
overview.HighFallbackCount++
|
||||
}
|
||||
}
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
func (s *aiObservabilityService) GetAdminSessionUsageOverview(query InstanceSessionUsageOverviewQuery) (*InstanceSessionUsageOverview, error) {
|
||||
if s.instanceRepo == nil {
|
||||
return &InstanceSessionUsageOverview{Items: []InstanceSessionUsageOverviewItem{}}, nil
|
||||
}
|
||||
|
||||
page, limit := normalizePageLimit(query.Page, query.Limit, 20, 100)
|
||||
filter := repository.SessionUsageFilter{Since: query.Since, Until: query.Until}
|
||||
search := strings.ToLower(strings.TrimSpace(query.Search))
|
||||
|
||||
instances, err := s.instanceRepo.GetAllRunning()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list running instances: %w", err)
|
||||
}
|
||||
|
||||
allItems := make([]InstanceSessionUsageOverviewItem, 0)
|
||||
globalSummary := InstanceSessionUsageSummary{Currency: "USD"}
|
||||
globalCurrencyCounts := map[string]int{}
|
||||
for _, instance := range instances {
|
||||
if !supportsManagedRuntimeIntegration(instance.Type) {
|
||||
continue
|
||||
}
|
||||
if search != "" {
|
||||
nameMatch := strings.Contains(strings.ToLower(instance.Name), search)
|
||||
idMatch := strings.Contains(strconv.Itoa(instance.ID), search)
|
||||
typeMatch := strings.Contains(strings.ToLower(instance.Type), search)
|
||||
if !nameMatch && !idMatch && !typeMatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
sessionItems, mergeErr := s.mergeInstanceSessionUsageItems(instance.ID, filter)
|
||||
if mergeErr != nil {
|
||||
return nil, mergeErr
|
||||
}
|
||||
summary, compliance := summarizeSessionUsageItems(sessionItems, filter, instance.ID, s.invocationRepo, s.auditRepo)
|
||||
item := InstanceSessionUsageOverviewItem{
|
||||
InstanceID: instance.ID,
|
||||
InstanceName: instance.Name,
|
||||
InstanceType: instance.Type,
|
||||
UserID: instance.UserID,
|
||||
Summary: summary,
|
||||
Compliance: compliance,
|
||||
}
|
||||
allItems = append(allItems, item)
|
||||
|
||||
globalSummary.TotalPromptTokens += summary.TotalPromptTokens
|
||||
globalSummary.TotalCompletionTokens += summary.TotalCompletionTokens
|
||||
globalSummary.TotalTokens += summary.TotalTokens
|
||||
globalSummary.TotalEstimatedCost += summary.TotalEstimatedCost
|
||||
globalSummary.SessionCount += summary.SessionCount
|
||||
if strings.TrimSpace(summary.Currency) != "" {
|
||||
globalCurrencyCounts[summary.Currency]++
|
||||
}
|
||||
}
|
||||
if len(globalCurrencyCounts) > 0 {
|
||||
globalSummary.Currency = pickDominantCurrency(globalCurrencyCounts)
|
||||
}
|
||||
|
||||
sort.Slice(allItems, func(i, j int) bool {
|
||||
if allItems[i].Summary.TotalTokens == allItems[j].Summary.TotalTokens {
|
||||
return allItems[i].InstanceID > allItems[j].InstanceID
|
||||
}
|
||||
return allItems[i].Summary.TotalTokens > allItems[j].Summary.TotalTokens
|
||||
})
|
||||
|
||||
total := len(allItems)
|
||||
start := (page - 1) * limit
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
paged := make([]InstanceSessionUsageOverviewItem, 0, end-start)
|
||||
if start < end {
|
||||
paged = append(paged, allItems[start:end]...)
|
||||
}
|
||||
|
||||
return &InstanceSessionUsageOverview{
|
||||
Summary: globalSummary,
|
||||
Items: paged,
|
||||
Total: total,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func summarizeSessionUsageItems(
|
||||
allItems []InstanceSessionUsageItem,
|
||||
filter repository.SessionUsageFilter,
|
||||
instanceID int,
|
||||
invocationRepo repository.ModelInvocationRepository,
|
||||
auditRepo repository.AuditEventRepository,
|
||||
) (InstanceSessionUsageSummary, InstanceSessionUsageCompliance) {
|
||||
summary := InstanceSessionUsageSummary{Currency: "USD"}
|
||||
compliance := InstanceSessionUsageCompliance{}
|
||||
currencyCounts := map[string]int{}
|
||||
for _, item := range allItems {
|
||||
summary.TotalPromptTokens += item.PromptTokens
|
||||
summary.TotalCompletionTokens += item.CompletionTokens
|
||||
summary.TotalTokens += item.TotalTokens
|
||||
summary.TotalEstimatedCost += item.EstimatedCost
|
||||
if strings.TrimSpace(item.Currency) != "" {
|
||||
currencyCounts[item.Currency]++
|
||||
}
|
||||
if utils.IsTraceFallbackSessionID(item.SessionID) {
|
||||
compliance.FallbackSessionCount++
|
||||
}
|
||||
}
|
||||
summary.SessionCount = len(allItems)
|
||||
if invocationRepo != nil {
|
||||
if count, countErr := invocationRepo.CountDistinctSessionsByInstance(instanceID, filter); countErr == nil {
|
||||
summary.SessionCount = count
|
||||
}
|
||||
}
|
||||
compliance.HasFallbackSessions = compliance.FallbackSessionCount > 0
|
||||
if auditRepo != nil {
|
||||
since := time.Now().UTC().Add(-24 * time.Hour)
|
||||
if filter.Since != nil && filter.Since.After(since) {
|
||||
since = *filter.Since
|
||||
}
|
||||
if count, err := auditRepo.CountRecentByInstanceAndEventType(instanceID, "gateway.session.fallback", since); err == nil {
|
||||
compliance.RecentFallbackAuditCount = count
|
||||
}
|
||||
}
|
||||
if len(currencyCounts) > 0 {
|
||||
summary.Currency = pickDominantCurrency(currencyCounts)
|
||||
}
|
||||
return summary, compliance
|
||||
}
|
||||
|
||||
func pickDominantCurrency(currencyCounts map[string]int) string {
|
||||
bestCurrency := "USD"
|
||||
bestCount := 0
|
||||
for currency, count := range currencyCounts {
|
||||
if count > bestCount {
|
||||
bestCurrency = currency
|
||||
bestCount = count
|
||||
}
|
||||
}
|
||||
return bestCurrency
|
||||
}
|
||||
|
||||
func decodeRuntimeSystemInfo(runtimeRepo repository.InstanceRuntimeStatusRepository, instanceID int) map[string]interface{} {
|
||||
if runtimeRepo == nil {
|
||||
return nil
|
||||
}
|
||||
status, err := runtimeRepo.GetByInstanceID(instanceID)
|
||||
if err != nil || status == nil || status.SystemInfoJSON == nil {
|
||||
return nil
|
||||
}
|
||||
raw := strings.TrimSpace(*status.SystemInfoJSON)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
systemInfo := map[string]interface{}{}
|
||||
if err := json.Unmarshal([]byte(raw), &systemInfo); err != nil {
|
||||
return nil
|
||||
}
|
||||
return systemInfo
|
||||
}
|
||||
|
||||
func (s *aiObservabilityService) mergeInstanceSessionUsageItems(instanceID int, filter repository.SessionUsageFilter) ([]InstanceSessionUsageItem, error) {
|
||||
tokenAggs, err := s.invocationRepo.AggregateByInstanceSession(instanceID, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to aggregate session tokens: %w", err)
|
||||
}
|
||||
costAggs, err := s.costRepo.AggregateCostByInstanceSession(instanceID, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to aggregate session costs: %w", err)
|
||||
}
|
||||
|
||||
sessionMeta := map[string]models.ChatSession{}
|
||||
if s.chatSessionRepo != nil {
|
||||
sessions, listErr := s.chatSessionRepo.ListByInstanceID(instanceID)
|
||||
if listErr != nil {
|
||||
return nil, fmt.Errorf("failed to list chat sessions: %w", listErr)
|
||||
}
|
||||
for _, session := range sessions {
|
||||
sessionMeta[session.SessionID] = session
|
||||
}
|
||||
}
|
||||
|
||||
costBySession := map[string]repository.InstanceSessionCostAggregate{}
|
||||
for _, cost := range costAggs {
|
||||
costBySession[cost.SessionID] = cost
|
||||
}
|
||||
|
||||
items := make([]InstanceSessionUsageItem, 0, len(tokenAggs))
|
||||
for _, token := range tokenAggs {
|
||||
item := InstanceSessionUsageItem{
|
||||
SessionID: token.SessionID,
|
||||
SessionKey: utils.FormatOpenClawSessionKey(token.SessionID),
|
||||
PromptTokens: token.PromptTokens,
|
||||
CompletionTokens: token.CompletionTokens,
|
||||
TotalTokens: token.TotalTokens,
|
||||
InvocationCount: token.InvocationCount,
|
||||
FirstSeenAt: token.FirstSeenAt,
|
||||
LastSeenAt: token.LastSeenAt,
|
||||
Currency: "USD",
|
||||
}
|
||||
if cost, ok := costBySession[token.SessionID]; ok {
|
||||
item.EstimatedCost = cost.EstimatedCost
|
||||
if strings.TrimSpace(cost.Currency) != "" {
|
||||
item.Currency = cost.Currency
|
||||
}
|
||||
}
|
||||
if session, ok := sessionMeta[token.SessionID]; ok && session.Title != nil {
|
||||
item.Title = session.Title
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].LastSeenAt.After(items[j].LastSeenAt)
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func filterSessionUsageItems(items []InstanceSessionUsageItem, search string) []InstanceSessionUsageItem {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
if search == "" {
|
||||
return items
|
||||
}
|
||||
filtered := make([]InstanceSessionUsageItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
haystacks := []string{
|
||||
strings.ToLower(item.SessionID),
|
||||
strings.ToLower(item.SessionKey),
|
||||
}
|
||||
if item.Title != nil {
|
||||
haystacks = append(haystacks, strings.ToLower(*item.Title))
|
||||
}
|
||||
matched := false
|
||||
for _, candidate := range haystacks {
|
||||
if strings.Contains(candidate, search) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func classifyLLMConfigStatusFromSystemInfo(systemInfo map[string]interface{}) string {
|
||||
if len(systemInfo) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
if raw, ok := systemInfo["llm_config_status"]; ok {
|
||||
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
if raw, ok := systemInfo["llm_provider_base_url"]; ok {
|
||||
if value, ok := raw.(string); ok {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
switch {
|
||||
case strings.Contains(lower, "gateway/llm"), strings.Contains(lower, "clawmanager"):
|
||||
return "gateway"
|
||||
case strings.Contains(lower, "openai.com"), strings.Contains(lower, "anthropic.com"):
|
||||
return "external"
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
)
|
||||
|
||||
type stubSessionUsageInvocationRepo struct {
|
||||
aggregates []repository.InstanceSessionTokenAggregate
|
||||
invocations []models.ModelInvocation
|
||||
}
|
||||
|
||||
func (s *stubSessionUsageInvocationRepo) Create(*models.ModelInvocation) error { return nil }
|
||||
func (s *stubSessionUsageInvocationRepo) GetByID(int) (*models.ModelInvocation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageInvocationRepo) ListByTraceID(string) ([]models.ModelInvocation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageInvocationRepo) ListBySessionID(string, int) ([]models.ModelInvocation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageInvocationRepo) ListByUserID(int, int) ([]models.ModelInvocation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageInvocationRepo) ListRecent(int) ([]models.ModelInvocation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageInvocationRepo) AggregateByInstanceSession(int, repository.SessionUsageFilter) ([]repository.InstanceSessionTokenAggregate, error) {
|
||||
return s.aggregates, nil
|
||||
}
|
||||
func (s *stubSessionUsageInvocationRepo) ListRecentByInstanceSession(int, string, int, repository.SessionUsageFilter) ([]models.ModelInvocation, error) {
|
||||
return s.invocations, nil
|
||||
}
|
||||
func (s *stubSessionUsageInvocationRepo) CountDistinctSessionsByInstance(int, repository.SessionUsageFilter) (int, error) {
|
||||
return len(s.aggregates), nil
|
||||
}
|
||||
|
||||
type stubSessionUsageCostRepo struct {
|
||||
aggregates []repository.InstanceSessionCostAggregate
|
||||
byTraceID map[string][]models.CostRecord
|
||||
}
|
||||
|
||||
func (s *stubSessionUsageCostRepo) Create(*models.CostRecord) error { return nil }
|
||||
func (s *stubSessionUsageCostRepo) ListByTraceID(traceID string) ([]models.CostRecord, error) {
|
||||
if s.byTraceID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.byTraceID[traceID], nil
|
||||
}
|
||||
func (s *stubSessionUsageCostRepo) ListByUserID(int, int) ([]models.CostRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageCostRepo) ListRecent(int) ([]models.CostRecord, error) { return nil, nil }
|
||||
func (s *stubSessionUsageCostRepo) AggregateCostByInstanceSession(int, repository.SessionUsageFilter) ([]repository.InstanceSessionCostAggregate, error) {
|
||||
return s.aggregates, nil
|
||||
}
|
||||
|
||||
type stubSessionUsageChatSessionRepo struct {
|
||||
sessions []models.ChatSession
|
||||
}
|
||||
|
||||
func (s *stubSessionUsageChatSessionRepo) GetBySessionID(string) (*models.ChatSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageChatSessionRepo) ListByInstanceID(int) ([]models.ChatSession, error) {
|
||||
return s.sessions, nil
|
||||
}
|
||||
func (s *stubSessionUsageChatSessionRepo) Save(*models.ChatSession) error { return nil }
|
||||
|
||||
type stubSessionUsageAuditRepo struct {
|
||||
counts map[string]int
|
||||
}
|
||||
|
||||
func (s *stubSessionUsageAuditRepo) Create(*models.AuditEvent) error { return nil }
|
||||
func (s *stubSessionUsageAuditRepo) ListByTraceID(string) ([]models.AuditEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageAuditRepo) ListRecent(int) ([]models.AuditEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubSessionUsageAuditRepo) CountRecentByInstanceAndEventType(instanceID int, eventType string, since time.Time) (int, error) {
|
||||
if s.counts == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return s.counts[eventType], nil
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageMergesInvocationCostAndSessionMetadata(t *testing.T) {
|
||||
now := time.Date(2026, 7, 2, 10, 0, 0, 0, time.UTC)
|
||||
title := "Weather chat"
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{
|
||||
SessionID: "agent:openclaw:main",
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 40,
|
||||
TotalTokens: 140,
|
||||
InvocationCount: 2,
|
||||
FirstSeenAt: now.Add(-time.Hour),
|
||||
LastSeenAt: now,
|
||||
},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{
|
||||
aggregates: []repository.InstanceSessionCostAggregate{
|
||||
{
|
||||
SessionID: "agent:openclaw:main",
|
||||
EstimatedCost: 0.12,
|
||||
Currency: "USD",
|
||||
},
|
||||
},
|
||||
},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{
|
||||
sessions: []models.ChatSession{
|
||||
{SessionID: "agent:openclaw:main", Title: &title},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.GetInstanceSessionUsage(9, InstanceSessionUsageQuery{Page: 1, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceSessionUsage failed: %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(result.Items))
|
||||
}
|
||||
item := result.Items[0]
|
||||
if item.SessionKey != "main" || item.TotalTokens != 140 || item.EstimatedCost != 0.12 {
|
||||
t.Fatalf("unexpected merged item: %+v", item)
|
||||
}
|
||||
if item.Title == nil || *item.Title != title {
|
||||
t.Fatalf("expected title merge, got %+v", item.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageSearchFiltersBySessionKey(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 10, LastSeenAt: now},
|
||||
{SessionID: "agent:openclaw:research", TotalTokens: 20, LastSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
}
|
||||
|
||||
result, err := service.GetInstanceSessionUsage(9, InstanceSessionUsageQuery{Page: 1, Limit: 10, Search: "research"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceSessionUsage failed: %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].SessionKey != "research" {
|
||||
t.Fatalf("unexpected filtered items: %+v", result.Items)
|
||||
}
|
||||
if result.Summary.SessionCount != 2 || result.Summary.TotalTokens != 30 {
|
||||
t.Fatalf("summary should ignore search filter, got %+v", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageComplianceCountsFallbackSessions(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 10, LastSeenAt: now},
|
||||
{SessionID: "sess_trc_abc", TotalTokens: 5, LastSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
}
|
||||
|
||||
result, err := service.GetInstanceSessionUsage(9, InstanceSessionUsageQuery{Page: 1, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceSessionUsage failed: %v", err)
|
||||
}
|
||||
if !result.Compliance.HasFallbackSessions || result.Compliance.FallbackSessionCount != 1 {
|
||||
t.Fatalf("unexpected compliance: %+v", result.Compliance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageComplianceIncludesFallbackAuditCount(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 10, LastSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
auditRepo: &stubSessionUsageAuditRepo{
|
||||
counts: map[string]int{"gateway.session.fallback": 3},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := service.GetInstanceSessionUsage(9, InstanceSessionUsageQuery{Page: 1, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceSessionUsage failed: %v", err)
|
||||
}
|
||||
if result.Compliance.RecentFallbackAuditCount != 3 {
|
||||
t.Fatalf("expected recent fallback audit count 3, got %+v", result.Compliance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceSessionUsageDetailBuildsModelBreakdown(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{
|
||||
SessionID: "agent:openclaw:main",
|
||||
TotalTokens: 30,
|
||||
LastSeenAt: now,
|
||||
FirstSeenAt: now,
|
||||
InvocationCount: 1,
|
||||
},
|
||||
},
|
||||
invocations: []models.ModelInvocation{
|
||||
{
|
||||
TraceID: "trc_1",
|
||||
RequestedModel: "auto",
|
||||
Status: models.ModelInvocationStatusCompleted,
|
||||
PromptTokens: 20,
|
||||
CompletionTokens: 10,
|
||||
TotalTokens: 30,
|
||||
CreatedAt: now,
|
||||
},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{
|
||||
byTraceID: map[string][]models.CostRecord{
|
||||
"trc_1": {
|
||||
{TraceID: "trc_1", EstimatedCost: 0.05, Currency: "USD"},
|
||||
},
|
||||
},
|
||||
},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
llmModelRepo: &stubLLMModelRepository{},
|
||||
}
|
||||
|
||||
detail, err := service.GetInstanceSessionUsageDetail(9, "agent:openclaw:main", repository.SessionUsageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceSessionUsageDetail failed: %v", err)
|
||||
}
|
||||
if len(detail.ModelBreakdown) != 1 || detail.ModelBreakdown[0].Label != "auto" {
|
||||
t.Fatalf("unexpected model breakdown: %+v", detail.ModelBreakdown)
|
||||
}
|
||||
if detail.ModelBreakdown[0].EstimatedCost != 0.05 {
|
||||
t.Fatalf("expected model breakdown cost 0.05, got %+v", detail.ModelBreakdown[0])
|
||||
}
|
||||
if len(detail.RecentTraces) != 1 || detail.RecentTraces[0].TraceID != "trc_1" {
|
||||
t.Fatalf("unexpected recent traces: %+v", detail.RecentTraces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceLLMGovernanceStatusUnknownConfigUsesFallbackOnly(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 10, LastSeenAt: now, FirstSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
auditRepo: &stubSessionUsageAuditRepo{
|
||||
counts: map[string]int{"egress.llm.blocked": 2},
|
||||
},
|
||||
}
|
||||
|
||||
status, err := service.GetInstanceLLMGovernanceStatus(9, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceLLMGovernanceStatus failed: %v", err)
|
||||
}
|
||||
if !status.IsCompliant || status.ConfigStatus != "unknown" || status.RecentEgressBlockCount != 2 {
|
||||
t.Fatalf("unexpected governance status: %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceLLMGovernanceStatusExternalConfigIsNonCompliant(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 10, LastSeenAt: now, FirstSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
}
|
||||
|
||||
status, err := service.GetInstanceLLMGovernanceStatus(9, map[string]interface{}{
|
||||
"llm_config_status": "external",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetInstanceLLMGovernanceStatus failed: %v", err)
|
||||
}
|
||||
if status.IsCompliant {
|
||||
t.Fatalf("expected external config to be non-compliant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdminSessionUsageOverviewAggregatesManagedInstances(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 100, LastSeenAt: now, FirstSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
instanceRepo: &stubGovernanceInstanceRepo{
|
||||
instances: []models.Instance{
|
||||
{ID: 1, UserID: 9, Name: "oc-1", Type: "openclaw", Status: "running"},
|
||||
{ID: 2, UserID: 10, Name: "ubuntu", Type: "ubuntu", Status: "running"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
overview, err := service.GetAdminSessionUsageOverview(InstanceSessionUsageOverviewQuery{Page: 1, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdminSessionUsageOverview failed: %v", err)
|
||||
}
|
||||
if overview.Total != 1 || len(overview.Items) != 1 {
|
||||
t.Fatalf("expected 1 managed instance item, got total=%d items=%d", overview.Total, len(overview.Items))
|
||||
}
|
||||
if overview.Summary.TotalTokens != 100 {
|
||||
t.Fatalf("unexpected global summary: %+v", overview.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdminSessionUsageOverviewFiltersBySearch(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 100, LastSeenAt: now, FirstSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
instanceRepo: &stubGovernanceInstanceRepo{
|
||||
instances: []models.Instance{
|
||||
{ID: 1, UserID: 9, Name: "alpha-openclaw", Type: "openclaw", Status: "running"},
|
||||
{ID: 2, UserID: 9, Name: "beta-openclaw", Type: "openclaw", Status: "running"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
overview, err := service.GetAdminSessionUsageOverview(InstanceSessionUsageOverviewQuery{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
Search: "alpha",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdminSessionUsageOverview failed: %v", err)
|
||||
}
|
||||
if overview.Total != 1 || len(overview.Items) != 1 || overview.Items[0].InstanceName != "alpha-openclaw" {
|
||||
t.Fatalf("unexpected filtered overview: total=%d items=%+v", overview.Total, overview.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdminSessionUsageOverviewAcceptsSinceQuery(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
since := now.Add(-24 * time.Hour)
|
||||
service := &aiObservabilityService{
|
||||
invocationRepo: &stubSessionUsageInvocationRepo{
|
||||
aggregates: []repository.InstanceSessionTokenAggregate{
|
||||
{SessionID: "agent:openclaw:main", TotalTokens: 50, LastSeenAt: now, FirstSeenAt: now},
|
||||
},
|
||||
},
|
||||
costRepo: &stubSessionUsageCostRepo{},
|
||||
chatSessionRepo: &stubSessionUsageChatSessionRepo{},
|
||||
instanceRepo: &stubGovernanceInstanceRepo{
|
||||
instances: []models.Instance{
|
||||
{ID: 1, UserID: 9, Name: "oc-1", Type: "openclaw", Status: "running"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
overview, err := service.GetAdminSessionUsageOverview(InstanceSessionUsageOverviewQuery{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
Since: &since,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdminSessionUsageOverview failed: %v", err)
|
||||
}
|
||||
if overview.Total != 1 || overview.Summary.TotalTokens != 50 {
|
||||
t.Fatalf("unexpected since-filtered overview: total=%d summary=%+v", overview.Total, overview.Summary)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -10,6 +11,66 @@ import (
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
var protectedManagedRuntimeEnvKeys = map[string]struct{}{
|
||||
"CLAWMANAGER_LLM_BASE_URL": {},
|
||||
"CLAWMANAGER_LLM_API_KEY": {},
|
||||
"CLAWMANAGER_LLM_MODEL": {},
|
||||
"CLAWMANAGER_LLM_PROVIDER": {},
|
||||
"CLAWMANAGER_INSTANCE_TOKEN": {},
|
||||
"OPENAI_BASE_URL": {},
|
||||
"OPENAI_API_BASE": {},
|
||||
"OPENAI_API_KEY": {},
|
||||
"OPENAI_MODEL": {},
|
||||
}
|
||||
|
||||
func isLLMGovernanceStrictEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("CLAWMANAGER_LLM_GOVERNANCE_STRICT"))) {
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func isInstanceNetworkLockEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("CLAWMANAGER_INSTANCE_NETWORK_LOCK"))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isProtectedManagedRuntimeEnvKey(key string) bool {
|
||||
_, ok := protectedManagedRuntimeEnvKeys[strings.ToUpper(strings.TrimSpace(key))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func validateManagedRuntimeEnvironmentOverrides(instanceType string, overrides map[string]string) error {
|
||||
if !supportsManagedRuntimeIntegration(instanceType) || !isLLMGovernanceStrictEnabled() {
|
||||
return nil
|
||||
}
|
||||
for key := range overrides {
|
||||
if isProtectedManagedRuntimeEnvKey(key) {
|
||||
return fmt.Errorf("environment override %s is managed by the platform", strings.ToUpper(strings.TrimSpace(key)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyProtectedManagedRuntimeEnv(target, protected map[string]string) map[string]string {
|
||||
if len(protected) == 0 {
|
||||
return target
|
||||
}
|
||||
if target == nil {
|
||||
target = map[string]string{}
|
||||
}
|
||||
for key, value := range protected {
|
||||
target[key] = value
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
const (
|
||||
defaultInstanceSHMSizeGB = 1
|
||||
maxInstanceSHMSizeGB = 8
|
||||
@@ -127,6 +188,9 @@ func buildInstancePodEnv(instance *models.Instance, runtimeEnv, gatewayEnv, agen
|
||||
delete(resolved, "SUBFOLDER")
|
||||
}
|
||||
resolved = mergeEnvMaps(resolved, overrides)
|
||||
if supportsManagedRuntimeIntegration(instance.Type) && isLLMGovernanceStrictEnabled() {
|
||||
resolved = applyProtectedManagedRuntimeEnv(resolved, mergeEnvMaps(gatewayEnv, agentEnv))
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
@@ -145,6 +209,9 @@ func buildInstanceGatewayEnv(instance *models.Instance, gatewayEnv map[string]st
|
||||
resolved = withInstanceProxyEnv(instance.Type, instance.ID, resolved)
|
||||
resolved["CLAWMANAGER_RUNTIME_TYPE"] = normalizeInstanceRuntimeType(instance.RuntimeType)
|
||||
resolved = mergeEnvMaps(resolved, overrides)
|
||||
if supportsManagedRuntimeIntegration(instance.Type) && isLLMGovernanceStrictEnabled() {
|
||||
resolved = applyProtectedManagedRuntimeEnv(resolved, gatewayEnv)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
func TestNormalizeEnvironmentOverrides(t *testing.T) {
|
||||
overrides, err := normalizeEnvironmentOverrides(map[string]string{
|
||||
" FOO ": "bar",
|
||||
"BAR_2": "",
|
||||
func TestValidateManagedRuntimeEnvironmentOverridesRejectsProtectedKeys(t *testing.T) {
|
||||
err := validateManagedRuntimeEnvironmentOverrides("openclaw", map[string]string{
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected protected env override to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProtectedManagedRuntimeEnvRestoresGatewayValues(t *testing.T) {
|
||||
target := map[string]string{
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
"CUSTOM": "value",
|
||||
}
|
||||
protected := map[string]string{
|
||||
"OPENAI_BASE_URL": "http://gateway.example/api/v1/gateway/llm",
|
||||
}
|
||||
result := applyProtectedManagedRuntimeEnv(target, protected)
|
||||
if result["OPENAI_BASE_URL"] != protected["OPENAI_BASE_URL"] {
|
||||
t.Fatalf("expected protected gateway url, got %q", result["OPENAI_BASE_URL"])
|
||||
}
|
||||
if result["CUSTOM"] != "value" {
|
||||
t.Fatalf("expected custom override to remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManagedRuntimeEnvironmentOverridesAllowsCustomKeys(t *testing.T) {
|
||||
err := validateManagedRuntimeEnvironmentOverrides("openclaw", map[string]string{
|
||||
"CUSTOM_FLAG": "1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeEnvironmentOverrides returned error: %v", err)
|
||||
}
|
||||
|
||||
if overrides["FOO"] != "bar" {
|
||||
t.Fatalf("expected trimmed key FOO to be preserved")
|
||||
}
|
||||
if value, ok := overrides["BAR_2"]; !ok || value != "" {
|
||||
t.Fatalf("expected empty override value to be preserved")
|
||||
t.Fatalf("expected custom override to be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,73 +101,20 @@ func TestBuildInstancePodEnvAppliesOverridesAfterDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInstancePodEnvNormalizesDesktopStreamProfile(t *testing.T) {
|
||||
raw, err := marshalEnvironmentOverrides(map[string]string{
|
||||
"CLAWMANAGER_DESKTOP_STREAM_PROFILE": "standard",
|
||||
"SELKIES_ENCODER": "x264enc",
|
||||
"SELKIES_FRAMERATE": "35",
|
||||
"SELKIES_H264_CRF": "34",
|
||||
func TestValidateManagedRuntimeEnvironmentOverridesSkipsNonManagedTypes(t *testing.T) {
|
||||
err := validateManagedRuntimeEnvironmentOverrides("ubuntu", map[string]string{
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshalEnvironmentOverrides returned error: %v", err)
|
||||
}
|
||||
|
||||
env, err := buildInstancePodEnv(&models.Instance{
|
||||
ID: 42,
|
||||
Type: "openclaw",
|
||||
RuntimeType: RuntimeBackendDesktop,
|
||||
EnvironmentOverridesJSON: raw,
|
||||
}, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInstancePodEnv returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := env["SELKIES_ENCODER"]; got != "x264enc,jpeg" {
|
||||
t.Fatalf("SELKIES_ENCODER = %q, want x264enc,jpeg", got)
|
||||
}
|
||||
if got := env["SELKIES_USE_CSS_SCALING"]; got != "true" {
|
||||
t.Fatalf("SELKIES_USE_CSS_SCALING = %q, want true", got)
|
||||
t.Fatalf("expected non-managed type to skip validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopSHMSizeGB(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
hasValue bool
|
||||
runtime string
|
||||
memoryGB int
|
||||
want int
|
||||
}{
|
||||
{name: "desktop minimum preset", runtime: "desktop", memoryGB: 4, want: defaultInstanceSHMSizeGB},
|
||||
{name: "desktop medium preset", runtime: "desktop", memoryGB: 8, want: 2},
|
||||
{name: "desktop large preset", runtime: "desktop", memoryGB: 12, want: 4},
|
||||
{name: "desktop small fallback", runtime: "desktop", memoryGB: 2, want: defaultInstanceSHMSizeGB},
|
||||
{name: "shell default unchanged", runtime: "shell", memoryGB: 8, want: defaultInstanceSHMSizeGB},
|
||||
{name: "disable", value: "0", hasValue: true, runtime: "desktop", memoryGB: 8, want: 0},
|
||||
{name: "custom", value: "4", hasValue: true, runtime: "desktop", memoryGB: 4, want: 4},
|
||||
{name: "clamp", value: "128", hasValue: true, runtime: "desktop", memoryGB: 8, want: maxInstanceSHMSizeGB},
|
||||
{name: "invalid keeps dynamic desktop default", value: "nope", hasValue: true, runtime: "desktop", memoryGB: 8, want: 2},
|
||||
{name: "negative keeps dynamic desktop default", value: "-1", hasValue: true, runtime: "desktop", memoryGB: 4, want: defaultInstanceSHMSizeGB},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
extraEnv := map[string]string{"KEEP": "value"}
|
||||
if tt.hasValue {
|
||||
extraEnv["SHM_SIZE_GB"] = tt.value
|
||||
}
|
||||
|
||||
got := popSHMSizeGB(extraEnv, tt.runtime, tt.memoryGB)
|
||||
if got != tt.want {
|
||||
t.Fatalf("expected shm size %d, got %d", tt.want, got)
|
||||
}
|
||||
if _, ok := extraEnv["SHM_SIZE_GB"]; ok {
|
||||
t.Fatalf("expected SHM_SIZE_GB to be removed from extra env")
|
||||
}
|
||||
if extraEnv["KEEP"] != "value" {
|
||||
t.Fatalf("expected unrelated env to be preserved")
|
||||
}
|
||||
})
|
||||
func TestValidateManagedRuntimeEnvironmentOverridesErrorMessage(t *testing.T) {
|
||||
err := validateManagedRuntimeEnvironmentOverrides("openclaw", map[string]string{
|
||||
"openai_api_key": "sk-test",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "OPENAI_API_KEY") {
|
||||
t.Fatalf("expected normalized key in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ func withInstanceProxyEnv(instanceType string, instanceID int, env map[string]st
|
||||
merged["https_proxy"] = proxyURL
|
||||
merged["NO_PROXY"] = noProxy
|
||||
merged["no_proxy"] = noProxy
|
||||
merged["CLAWMANAGER_EGRESS_INSTANCE_ID"] = fmt.Sprintf("%d", instanceID)
|
||||
}
|
||||
|
||||
if usesWebtopImage(instanceType) {
|
||||
|
||||
@@ -19,10 +19,13 @@ type AgentStateReportRequest struct {
|
||||
}
|
||||
|
||||
type AgentRuntimePayload struct {
|
||||
OpenClawStatus string `json:"openclaw_status"`
|
||||
OpenClawPID *int `json:"openclaw_pid,omitempty"`
|
||||
OpenClawVersion string `json:"openclaw_version"`
|
||||
CurrentConfigRevisionID *int `json:"current_config_revision_id,omitempty"`
|
||||
OpenClawStatus string `json:"openclaw_status"`
|
||||
OpenClawPID *int `json:"openclaw_pid,omitempty"`
|
||||
OpenClawVersion string `json:"openclaw_version"`
|
||||
CurrentConfigRevisionID *int `json:"current_config_revision_id,omitempty"`
|
||||
LLMConfigFingerprint *string `json:"llm_config_fingerprint,omitempty"`
|
||||
LLMConfigStatus *string `json:"llm_config_status,omitempty"`
|
||||
LLMProviderBaseURL *string `json:"llm_provider_base_url,omitempty"`
|
||||
}
|
||||
|
||||
type InstanceRuntimeStatusPayload struct {
|
||||
@@ -90,7 +93,7 @@ func (s *instanceRuntimeStatusService) Report(session *AgentSession, req AgentSt
|
||||
status.CurrentConfigRevisionID = req.Runtime.CurrentConfigRevisionID
|
||||
status.LastReportedAt = reportedAt
|
||||
|
||||
systemInfoJSON, err := marshalOptionalJSON(req.SystemInfo)
|
||||
systemInfoJSON, err := marshalOptionalJSON(mergeLLMConfigIntoSystemInfo(req.SystemInfo, req.Runtime))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode system info: %w", err)
|
||||
}
|
||||
@@ -152,6 +155,26 @@ func (s *instanceRuntimeStatusService) GetByInstanceID(instanceID int) (*Instanc
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func mergeLLMConfigIntoSystemInfo(systemInfo map[string]interface{}, runtime AgentRuntimePayload) map[string]interface{} {
|
||||
merged := map[string]interface{}{}
|
||||
for key, value := range systemInfo {
|
||||
merged[key] = value
|
||||
}
|
||||
if runtime.LLMConfigFingerprint != nil && strings.TrimSpace(*runtime.LLMConfigFingerprint) != "" {
|
||||
merged["llm_config_fingerprint"] = strings.TrimSpace(*runtime.LLMConfigFingerprint)
|
||||
}
|
||||
if runtime.LLMConfigStatus != nil && strings.TrimSpace(*runtime.LLMConfigStatus) != "" {
|
||||
merged["llm_config_status"] = strings.TrimSpace(*runtime.LLMConfigStatus)
|
||||
}
|
||||
if runtime.LLMProviderBaseURL != nil && strings.TrimSpace(*runtime.LLMProviderBaseURL) != "" {
|
||||
merged["llm_provider_base_url"] = strings.TrimSpace(*runtime.LLMProviderBaseURL)
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return nil
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func (s *instanceRuntimeStatusService) UpsertInfraStatus(instanceID int, infraStatus string) error {
|
||||
status, err := s.getOrCreate(instanceID)
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,9 @@ func (s *instanceService) ValidateCreateRequests(userID int, requests []CreateIn
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateManagedRuntimeEnvironmentOverrides(requests[idx].Type, environmentOverrides); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := marshalEnvironmentOverrides(environmentOverrides); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -283,6 +286,9 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateManagedRuntimeEnvironmentOverrides(req.Type, environmentOverrides); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile, ok := normalizeDesktopStreamProfile(req.DesktopStreamProfile); !ok {
|
||||
return nil, fmt.Errorf("invalid desktop stream profile")
|
||||
} else if profile != "" {
|
||||
@@ -465,26 +471,23 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
|
||||
|
||||
var bootstrapSnapshot *models.OpenClawInjectionSnapshot
|
||||
var bootstrapSecretName string
|
||||
if supportsRuntimeConfigInjection(instance.Type) && s.openClawConfigService != nil && req.OpenClawConfigPlan != nil && hasOpenClawConfigSelections(*req.OpenClawConfigPlan) {
|
||||
bootstrapSnapshot, err = s.openClawConfigService.CreateSnapshotForInstance(userID, instance, req.OpenClawConfigPlan)
|
||||
if err != nil {
|
||||
if snapshot, snapshotErr := s.createRuntimeBootstrapSnapshot(userID, instance, req.OpenClawConfigPlan); snapshotErr != nil {
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to compile runtime bootstrap config: %w", snapshotErr)
|
||||
} else if snapshot != nil {
|
||||
bootstrapSnapshot = snapshot
|
||||
instance.OpenClawConfigSnapshotID = &bootstrapSnapshot.ID
|
||||
instance.UpdatedAt = time.Now()
|
||||
if err := s.instanceRepo.Update(instance); err != nil {
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to compile runtime bootstrap config: %w", err)
|
||||
return nil, fmt.Errorf("failed to persist runtime snapshot reference: %w", err)
|
||||
}
|
||||
if bootstrapSnapshot != nil {
|
||||
instance.OpenClawConfigSnapshotID = &bootstrapSnapshot.ID
|
||||
instance.UpdatedAt = time.Now()
|
||||
if err := s.instanceRepo.Update(instance); err != nil {
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to persist runtime snapshot reference: %w", err)
|
||||
}
|
||||
|
||||
bootstrapSecretName, err = s.openClawConfigService.EnsureSnapshotSecret(ctx, userID, instance, bootstrapSnapshot.ID)
|
||||
if err != nil {
|
||||
_ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err)
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to provision runtime bootstrap secret: %w", err)
|
||||
}
|
||||
bootstrapSecretName, err = s.openClawConfigService.EnsureSnapshotSecret(ctx, userID, instance, bootstrapSnapshot.ID)
|
||||
if err != nil {
|
||||
_ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err)
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to provision runtime bootstrap secret: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +505,14 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to create PVC: %w", err)
|
||||
}
|
||||
if err := EnsureInstanceWorkspacePathForServerScan(ctx, s.instanceRepo, instance); err != nil {
|
||||
s.pvcService.DeletePVC(ctx, userID, instance.ID)
|
||||
if bootstrapSnapshot != nil {
|
||||
_ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err)
|
||||
}
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodeSelector, err := s.pvcService.NodeSelectorForPVC(ctx, userID, instance.ID, storageClass)
|
||||
if err != nil {
|
||||
@@ -513,15 +524,14 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
|
||||
return nil, fmt.Errorf("failed to resolve PVC node selector: %w", err)
|
||||
}
|
||||
|
||||
// Ensure any legacy per-instance network policy is removed before creating pod.
|
||||
// This keeps new pods unrestricted even if older versions created netpols.
|
||||
if err := s.networkPolicyService.DeletePolicy(ctx, userID, instance.ID, instance.Name); err != nil {
|
||||
// Managed runtime network policy: optional egress lock when enabled.
|
||||
if err := s.syncInstanceNetworkPolicy(ctx, userID, instance); err != nil {
|
||||
s.pvcService.DeletePVC(ctx, userID, instance.ID)
|
||||
if bootstrapSnapshot != nil {
|
||||
_ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err)
|
||||
}
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to delete network policy: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create Pod
|
||||
@@ -743,24 +753,20 @@ func (s *instanceService) createV2Instance(ctx context.Context, userID int, req
|
||||
return nil, fmt.Errorf("failed to provision lite agent bootstrap token: %w", err)
|
||||
}
|
||||
|
||||
if supportsRuntimeConfigInjection(instance.Type) && s.openClawConfigService != nil && req.OpenClawConfigPlan != nil && hasOpenClawConfigSelections(*req.OpenClawConfigPlan) {
|
||||
bootstrapSnapshot, err := s.openClawConfigService.CreateSnapshotForInstance(userID, instance, req.OpenClawConfigPlan)
|
||||
if err != nil {
|
||||
if snapshot, snapshotErr := s.createRuntimeBootstrapSnapshot(userID, instance, req.OpenClawConfigPlan); snapshotErr != nil {
|
||||
_ = s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to compile lite runtime bootstrap config: %w", snapshotErr)
|
||||
} else if snapshot != nil {
|
||||
instance.OpenClawConfigSnapshotID = &snapshot.ID
|
||||
instance.UpdatedAt = time.Now()
|
||||
if err := s.instanceRepo.Update(instance); err != nil {
|
||||
_ = s.openClawConfigService.MarkSnapshotFailed(snapshot, err)
|
||||
_ = s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to compile lite runtime bootstrap config: %w", err)
|
||||
return nil, fmt.Errorf("failed to persist lite runtime snapshot reference: %w", err)
|
||||
}
|
||||
if bootstrapSnapshot != nil {
|
||||
instance.OpenClawConfigSnapshotID = &bootstrapSnapshot.ID
|
||||
instance.UpdatedAt = time.Now()
|
||||
if err := s.instanceRepo.Update(instance); err != nil {
|
||||
_ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err)
|
||||
_ = s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to persist lite runtime snapshot reference: %w", err)
|
||||
}
|
||||
if err := s.openClawConfigService.MarkSnapshotActive(bootstrapSnapshot); err != nil {
|
||||
_ = s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to activate lite runtime bootstrap snapshot: %w", err)
|
||||
}
|
||||
if err := s.openClawConfigService.MarkSnapshotActive(snapshot); err != nil {
|
||||
_ = s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to activate lite runtime bootstrap snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,6 +889,9 @@ func (s *instanceService) Start(instanceID int) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve instance environment: %w", err)
|
||||
}
|
||||
if err := EnsureInstanceWorkspacePathForServerScan(ctx, s.instanceRepo, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bootstrapSecretName := ""
|
||||
if supportsRuntimeConfigInjection(instance.Type) && s.openClawConfigService != nil && instance.OpenClawConfigSnapshotID != nil && *instance.OpenClawConfigSnapshotID > 0 {
|
||||
@@ -1168,6 +1177,7 @@ func (s *instanceService) buildAgentEnv(instance *models.Instance) (map[string]s
|
||||
"CLAWMANAGER_AGENT_INSTANCE_ID": fmt.Sprintf("%d", instance.ID),
|
||||
"CLAWMANAGER_AGENT_PERSISTENT_DIR": managedRuntimePersistentDir(instance),
|
||||
"CLAWMANAGER_AGENT_PROTOCOL_VERSION": AgentProtocolVersionV1,
|
||||
"CLAWMANAGER_AGENT_RUNTIME_TYPE": strings.ToLower(strings.TrimSpace(instance.Type)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1180,6 +1190,42 @@ func supportsManagedRuntimeIntegration(instanceType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *instanceService) createRuntimeBootstrapSnapshot(userID int, instance *models.Instance, plan *OpenClawConfigPlan) (*models.OpenClawInjectionSnapshot, error) {
|
||||
if !supportsRuntimeConfigInjection(instance.Type) || s.openClawConfigService == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if plan != nil && hasOpenClawConfigSelections(*plan) {
|
||||
return s.openClawConfigService.CreateSnapshotForInstance(userID, instance, plan)
|
||||
}
|
||||
if supportsManagedRuntimeIntegration(instance.Type) {
|
||||
return s.openClawConfigService.CreateDefaultLLMGovernanceSnapshot(userID, instance)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *instanceService) syncInstanceNetworkPolicy(ctx context.Context, userID int, instance *models.Instance) error {
|
||||
if instance == nil {
|
||||
return nil
|
||||
}
|
||||
if isLiteRuntimeInstance(instance) {
|
||||
// Lite/gateway-pool instances share runtime pods; per-instance NetworkPolicy does not apply.
|
||||
if err := s.networkPolicyService.DeletePolicy(ctx, userID, instance.ID, instance.Name); err != nil {
|
||||
return fmt.Errorf("failed to delete network policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if isInstanceNetworkLockEnabled() && supportsManagedRuntimeIntegration(instance.Type) {
|
||||
if err := s.networkPolicyService.EnsureDefaultPolicy(ctx, userID, instance.ID, instance.Name); err != nil {
|
||||
return fmt.Errorf("failed to ensure network policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := s.networkPolicyService.DeletePolicy(ctx, userID, instance.ID, instance.Name); err != nil {
|
||||
return fmt.Errorf("failed to delete network policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func supportsRuntimeConfigInjection(instanceType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(instanceType)) {
|
||||
case "openclaw", "hermes":
|
||||
@@ -1794,6 +1840,9 @@ func (s *instanceService) Update(instanceID int, req UpdateInstanceRequest) erro
|
||||
return err
|
||||
}
|
||||
environmentOverrides = applyDesktopStreamProfileEnv(environmentOverrides, profile)
|
||||
if err := validateManagedRuntimeEnvironmentOverrides(instance.Type, environmentOverrides); err != nil {
|
||||
return err
|
||||
}
|
||||
environmentOverridesJSON, err := marshalEnvironmentOverrides(environmentOverrides)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -698,6 +698,14 @@ func (s *liteOpenClawConfigStub) CreateSnapshotForInstance(userID int, instance
|
||||
return s.snapshot, nil
|
||||
}
|
||||
|
||||
func (s *liteOpenClawConfigStub) CreateDefaultLLMGovernanceSnapshot(userID int, instance *models.Instance) (*models.OpenClawInjectionSnapshot, error) {
|
||||
return s.CreateSnapshotForInstance(userID, instance, nil)
|
||||
}
|
||||
|
||||
func (s *liteOpenClawConfigStub) EnsurePlatformLLMGatewayResource(userID int) (*models.OpenClawConfigResource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *liteOpenClawConfigStub) MarkSnapshotActive(snapshot *models.OpenClawInjectionSnapshot) error {
|
||||
s.activated = true
|
||||
if snapshot != nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
"clawreef/internal/services/k8s"
|
||||
)
|
||||
|
||||
func hostPathWorkspaceScanEnabled() bool {
|
||||
client := k8s.GetClient()
|
||||
return client != nil && client.HostPathFallbackEnabled
|
||||
}
|
||||
|
||||
func instancePersistentHostPath(userID, instanceID int) (string, bool) {
|
||||
if !hostPathWorkspaceScanEnabled() || userID <= 0 || instanceID <= 0 {
|
||||
return "", false
|
||||
}
|
||||
hostPathPrefix := "/data/clawreef"
|
||||
if client := k8s.GetClient(); client != nil && strings.TrimSpace(client.HostPathPrefix) != "" {
|
||||
hostPathPrefix = strings.TrimSpace(client.HostPathPrefix)
|
||||
}
|
||||
return filepath.Join(hostPathPrefix, fmt.Sprintf("user-%d", userID), fmt.Sprintf("instance-%d", instanceID)), true
|
||||
}
|
||||
|
||||
func proDesktopWorkspaceScanEligible(instance *models.Instance) bool {
|
||||
if instance == nil || isLiteRuntimeInstance(instance) {
|
||||
return false
|
||||
}
|
||||
if v2Type, ok := v2RuntimeTypeForInstance(instance); ok && strings.TrimSpace(v2Type) != "" {
|
||||
return false
|
||||
}
|
||||
return supportsManagedRuntimeIntegration(instance.Type)
|
||||
}
|
||||
|
||||
func EnsureInstanceWorkspacePathForServerScan(ctx context.Context, repo repository.InstanceRepository, instance *models.Instance) error {
|
||||
if repo == nil || instance == nil {
|
||||
return nil
|
||||
}
|
||||
if instance.WorkspacePath != nil && strings.TrimSpace(*instance.WorkspacePath) != "" {
|
||||
return nil
|
||||
}
|
||||
if !proDesktopWorkspaceScanEligible(instance) {
|
||||
return nil
|
||||
}
|
||||
hostPath, ok := instancePersistentHostPath(instance.UserID, instance.ID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(hostPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := repo.SetWorkspacePath(ctx, instance.ID, hostPath); err != nil {
|
||||
return fmt.Errorf("failed to persist pro desktop workspace path: %w", err)
|
||||
}
|
||||
instance.WorkspacePath = &hostPath
|
||||
return nil
|
||||
}
|
||||
@@ -244,6 +244,7 @@ func BuildInstanceDeployment(client *Client, config PodConfig, replicas int32) *
|
||||
"runtime-type": runtimeType,
|
||||
"managed-by": "clawreef",
|
||||
}
|
||||
appendManagedRuntimeLabels(config.Type, labels)
|
||||
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
|
||||
@@ -55,9 +55,10 @@ func (s *NetworkPolicyService) EnsureDefaultPolicy(ctx context.Context, userID,
|
||||
Spec: networkingv1.NetworkPolicySpec{
|
||||
PodSelector: metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": "clawreef",
|
||||
"instance-id": instanceLabel,
|
||||
"managed-by": "clawreef",
|
||||
"app": "clawreef",
|
||||
"instance-id": instanceLabel,
|
||||
"managed-by": "clawreef",
|
||||
"clawmanager.io/managed-runtime": "true",
|
||||
},
|
||||
},
|
||||
PolicyTypes: []networkingv1.PolicyType{
|
||||
|
||||
@@ -156,6 +156,7 @@ func (s *PodService) CreatePod(ctx context.Context, config PodConfig) (*corev1.P
|
||||
"runtime-type": runtimeType,
|
||||
"managed-by": "clawreef",
|
||||
}
|
||||
appendManagedRuntimeLabels(config.Type, labels)
|
||||
|
||||
pod := &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package k8s
|
||||
|
||||
import "strings"
|
||||
|
||||
func appendManagedRuntimeLabels(instanceType string, labels map[string]string) {
|
||||
if labels == nil {
|
||||
return
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(instanceType)) {
|
||||
case "openclaw", "hermes":
|
||||
labels["clawmanager.io/managed-runtime"] = "true"
|
||||
}
|
||||
}
|
||||
@@ -263,6 +263,8 @@ type OpenClawConfigService interface {
|
||||
CompilePreview(userID int, plan OpenClawConfigPlan) (*OpenClawConfigCompilePreview, error)
|
||||
PlanWithoutTeamMemberLeaderOnlyChannels(userID int, plan *OpenClawConfigPlan) (*OpenClawConfigPlan, error)
|
||||
CreateSnapshotForInstance(userID int, instance *models.Instance, plan *OpenClawConfigPlan) (*models.OpenClawInjectionSnapshot, error)
|
||||
CreateDefaultLLMGovernanceSnapshot(userID int, instance *models.Instance) (*models.OpenClawInjectionSnapshot, error)
|
||||
EnsurePlatformLLMGatewayResource(userID int) (*models.OpenClawConfigResource, error)
|
||||
MarkSnapshotActive(snapshot *models.OpenClawInjectionSnapshot) error
|
||||
MarkSnapshotFailed(snapshot *models.OpenClawInjectionSnapshot, err error) error
|
||||
EnsureSnapshotSecret(ctx context.Context, userID int, instance *models.Instance, snapshotID int) (string, error)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
PlatformLLMGatewayResourceKey = "platform-llm-gateway"
|
||||
PlatformLLMGatewayResourceName = "Platform LLM Gateway"
|
||||
)
|
||||
|
||||
var platformLLMGatewayAgentContent = json.RawMessage(`{
|
||||
"schemaVersion": 1,
|
||||
"kind": "agent",
|
||||
"format": "agent/platform-llm-gateway@v1",
|
||||
"dependsOn": [],
|
||||
"config": {
|
||||
"models": {
|
||||
"providers": {
|
||||
"clawmanager": {
|
||||
"type": "openai-compatible",
|
||||
"baseUrl": "${CLAWMANAGER_LLM_BASE_URL}",
|
||||
"apiKey": "${CLAWMANAGER_LLM_API_KEY}",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"primary": "auto/auto"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (s *openClawConfigService) EnsurePlatformLLMGatewayResource(userID int) (*models.OpenClawConfigResource, error) {
|
||||
if userID <= 0 {
|
||||
return nil, fmt.Errorf("user id is required")
|
||||
}
|
||||
|
||||
existing, err := s.repo.GetResourceByUserTypeKey(userID, OpenClawConfigResourceTypeAgent, PlatformLLMGatewayResourceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
description := "Built-in agent config that routes OpenClaw LLM calls through the ClawManager AI Gateway."
|
||||
now := time.Now()
|
||||
item := &models.OpenClawConfigResource{
|
||||
UserID: userID,
|
||||
ResourceType: OpenClawConfigResourceTypeAgent,
|
||||
ResourceKey: PlatformLLMGatewayResourceKey,
|
||||
Name: PlatformLLMGatewayResourceName,
|
||||
Description: &description,
|
||||
Enabled: true,
|
||||
Version: 1,
|
||||
TagsJSON: encodeStringArray([]string{"builtin", "llm-governance", "platform"}),
|
||||
ContentJSON: string(platformLLMGatewayAgentContent),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.repo.CreateResource(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *openClawConfigService) CreateDefaultLLMGovernanceSnapshot(userID int, instance *models.Instance) (*models.OpenClawInjectionSnapshot, error) {
|
||||
if instance == nil || !supportsManagedRuntimeIntegration(instance.Type) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resource, err := s.EnsurePlatformLLMGatewayResource(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resource == nil || resource.ID <= 0 {
|
||||
return nil, fmt.Errorf("failed to provision platform llm gateway resource")
|
||||
}
|
||||
|
||||
plan := &OpenClawConfigPlan{
|
||||
Mode: OpenClawConfigPlanModeManual,
|
||||
ResourceIDs: []int{resource.ID},
|
||||
}
|
||||
return s.CreateSnapshotForInstance(userID, instance, plan)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
type platformLLMGatewayRepoStub struct {
|
||||
nextResourceID int
|
||||
nextSnapshotID int
|
||||
resources map[string]*models.OpenClawConfigResource
|
||||
}
|
||||
|
||||
func newPlatformLLMGatewayRepoStub() *platformLLMGatewayRepoStub {
|
||||
return &platformLLMGatewayRepoStub{
|
||||
nextResourceID: 1,
|
||||
nextSnapshotID: 1,
|
||||
resources: map[string]*models.OpenClawConfigResource{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *platformLLMGatewayRepoStub) resourceKey(userID int, resourceType, resourceKey string) string {
|
||||
return fmt.Sprintf("%d:%s:%s", userID, resourceType, resourceKey)
|
||||
}
|
||||
|
||||
func (s *platformLLMGatewayRepoStub) ListResources(userID int, resourceType string) ([]models.OpenClawConfigResource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) GetResourceByID(id int) (*models.OpenClawConfigResource, error) {
|
||||
for _, resource := range s.resources {
|
||||
if resource.ID == id {
|
||||
copy := *resource
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) GetResourceByUserTypeKey(userID int, resourceType, resourceKey string) (*models.OpenClawConfigResource, error) {
|
||||
resource := s.resources[s.resourceKey(userID, resourceType, resourceKey)]
|
||||
if resource == nil {
|
||||
return nil, nil
|
||||
}
|
||||
copy := *resource
|
||||
return ©, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) CreateResource(resource *models.OpenClawConfigResource) error {
|
||||
if resource == nil {
|
||||
return nil
|
||||
}
|
||||
resource.ID = s.nextResourceID
|
||||
s.nextResourceID++
|
||||
s.resources[s.resourceKey(resource.UserID, resource.ResourceType, resource.ResourceKey)] = resource
|
||||
return nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) UpdateResource(resource *models.OpenClawConfigResource) error {
|
||||
return nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) DeleteResource(id int) error { return nil }
|
||||
func (s *platformLLMGatewayRepoStub) ListBundles(userID int) ([]models.OpenClawConfigBundle, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) GetBundleByID(id int) (*models.OpenClawConfigBundle, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) CreateBundle(bundle *models.OpenClawConfigBundle) error { return nil }
|
||||
func (s *platformLLMGatewayRepoStub) UpdateBundle(bundle *models.OpenClawConfigBundle) error {
|
||||
return nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) DeleteBundle(id int) error { return nil }
|
||||
func (s *platformLLMGatewayRepoStub) ListBundleItems(bundleID int) ([]models.OpenClawConfigBundleItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) ReplaceBundleItems(bundleID int, items []models.OpenClawConfigBundleItem) error {
|
||||
return nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) ListBundleSkills(bundleID int) ([]models.OpenClawConfigBundleSkill, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) ReplaceBundleSkills(bundleID int, items []models.OpenClawConfigBundleSkill) error {
|
||||
return nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) CreateSnapshot(snapshot *models.OpenClawInjectionSnapshot) error {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
snapshot.ID = s.nextSnapshotID
|
||||
s.nextSnapshotID++
|
||||
return nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) UpdateSnapshot(snapshot *models.OpenClawInjectionSnapshot) error {
|
||||
return nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) GetSnapshotByID(id int) (*models.OpenClawInjectionSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) ListSnapshotsByUser(userID int, limit int) ([]models.OpenClawInjectionSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) ListActiveSnapshots(userID int) ([]models.OpenClawInjectionSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *platformLLMGatewayRepoStub) UpdateSnapshotIfUnchanged(snapshot *models.OpenClawInjectionSnapshot, expectedUpdatedAt time.Time) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestEnsurePlatformLLMGatewayResourceCreatesBuiltinAgentResource(t *testing.T) {
|
||||
repo := newPlatformLLMGatewayRepoStub()
|
||||
service := &openClawConfigService{repo: repo}
|
||||
|
||||
resource, err := service.EnsurePlatformLLMGatewayResource(9)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsurePlatformLLMGatewayResource returned error: %v", err)
|
||||
}
|
||||
if resource == nil || resource.ID <= 0 {
|
||||
t.Fatalf("expected created resource, got %+v", resource)
|
||||
}
|
||||
if resource.ResourceType != OpenClawConfigResourceTypeAgent || resource.ResourceKey != PlatformLLMGatewayResourceKey {
|
||||
t.Fatalf("unexpected resource identity: %+v", resource)
|
||||
}
|
||||
|
||||
again, err := service.EnsurePlatformLLMGatewayResource(9)
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsurePlatformLLMGatewayResource returned error: %v", err)
|
||||
}
|
||||
if again == nil || again.ID != resource.ID {
|
||||
t.Fatalf("expected same resource id, got %+v want %d", again, resource.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDefaultLLMGovernanceSnapshotCompilesPlatformGatewayAgent(t *testing.T) {
|
||||
repo := newPlatformLLMGatewayRepoStub()
|
||||
service := &openClawConfigService{repo: repo}
|
||||
instance := &models.Instance{ID: 42, UserID: 9, Type: "openclaw", Name: "oc-42"}
|
||||
|
||||
snapshot, err := service.CreateDefaultLLMGovernanceSnapshot(9, instance)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDefaultLLMGovernanceSnapshot returned error: %v", err)
|
||||
}
|
||||
if snapshot == nil {
|
||||
t.Fatal("expected snapshot")
|
||||
}
|
||||
if snapshot.Mode != OpenClawConfigPlanModeManual {
|
||||
t.Fatalf("snapshot mode = %q, want manual", snapshot.Mode)
|
||||
}
|
||||
if !strings.Contains(snapshot.ResolvedResourcesJSON, PlatformLLMGatewayResourceKey) {
|
||||
t.Fatalf("expected platform gateway resource in snapshot, got %s", snapshot.ResolvedResourcesJSON)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type RuntimeAgentClient interface {
|
||||
CreateGateway(ctx context.Context, endpoint string, req RuntimeAgentCreateGatewayRequest) (*RuntimeAgentCreateGatewayResponse, error)
|
||||
DeleteGateway(ctx context.Context, endpoint, gatewayID string) error
|
||||
Drain(ctx context.Context, endpoint string) error
|
||||
ResyncInstanceSkills(ctx context.Context, endpoint string, instanceID int, mode string) error
|
||||
}
|
||||
|
||||
type RuntimeAgentPortRange struct {
|
||||
@@ -91,6 +92,19 @@ func (c *runtimeAgentHTTPClient) Drain(ctx context.Context, endpoint string) err
|
||||
return c.do(ctx, http.MethodPost, endpoint, "/v1/drain", map[string]bool{"draining": true}, nil)
|
||||
}
|
||||
|
||||
func (c *runtimeAgentHTTPClient) ResyncInstanceSkills(ctx context.Context, endpoint string, instanceID int, mode string) error {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode == "" {
|
||||
mode = "full"
|
||||
}
|
||||
body := map[string]any{
|
||||
"instance_id": instanceID,
|
||||
"mode": mode,
|
||||
"trigger": "manual",
|
||||
}
|
||||
return c.do(ctx, http.MethodPost, endpoint, "/v1/skills/resync", body, nil)
|
||||
}
|
||||
|
||||
func (c *runtimeAgentHTTPClient) do(ctx context.Context, method, endpoint, path string, body any, out any) error {
|
||||
endpoint = strings.TrimRight(endpoint, "/")
|
||||
var reader io.Reader
|
||||
|
||||
@@ -192,6 +192,30 @@ func TestRuntimeAgentClientDrainSendsJSONBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAgentClientResyncInstanceSkills(t *testing.T) {
|
||||
var gotMethod, gotPath, gotToken string
|
||||
var body map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotToken = r.Header.Get("X-ClawManager-Control-Token")
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewRuntimeAgentClient("secret")
|
||||
if err := client.ResyncInstanceSkills(context.Background(), server.URL, 12, "full"); err != nil {
|
||||
t.Fatalf("ResyncInstanceSkills returned error: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != "/v1/skills/resync" || gotToken != "secret" {
|
||||
t.Fatalf("unexpected request: %s %s token=%q", gotMethod, gotPath, gotToken)
|
||||
}
|
||||
if body["instance_id"] != float64(12) || body["mode"] != "full" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAgentClientConflict(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "no free port", http.StatusConflict)
|
||||
|
||||
@@ -2367,6 +2367,9 @@ func (c *fakeRuntimeAgentClient) Drain(ctx context.Context, endpoint string) err
|
||||
c.drainEndpoints = append(c.drainEndpoints, endpoint)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeRuntimeAgentClient) ResyncInstanceSkills(ctx context.Context, endpoint string, instanceID int, mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeRuntimeEventService struct {
|
||||
published []fakeRuntimeEvent
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"clawreef/internal/utils"
|
||||
)
|
||||
|
||||
func TestHashDirectoryGoldenWeatherFixture(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"src/main.py": []byte("print('weather')\n"),
|
||||
}
|
||||
got := hashDirectory(files)
|
||||
want := referenceSkillContentMD5(files)
|
||||
if got != want {
|
||||
t.Fatalf("hashDirectory() = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubErrorMD5MismatchCode(t *testing.T) {
|
||||
err := utils.NewHubError(
|
||||
"skill_package_md5_mismatch",
|
||||
"skill package md5 mismatch: expected abc got def",
|
||||
map[string]string{"expected": "abc", "computed": "def"},
|
||||
)
|
||||
var hubErr *utils.HubError
|
||||
if !errors.As(err, &hubErr) {
|
||||
t.Fatal("expected HubError")
|
||||
}
|
||||
if hubErr.Code != "skill_package_md5_mismatch" {
|
||||
t.Fatalf("expected skill_package_md5_mismatch, got %q", hubErr.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
type skillRepoStub struct {
|
||||
skills map[int]*models.Skill
|
||||
blobs map[int]*models.SkillBlob
|
||||
versions map[int]*models.SkillVersion
|
||||
tags map[int]*models.SkillHubTag
|
||||
tagAssignments map[int][]int
|
||||
instanceSkillsBySkillID map[int][]models.InstanceSkill
|
||||
instanceSkills []models.InstanceSkill
|
||||
hardDeleteCalled bool
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ListSkillsByUser(userID int) ([]models.Skill, error) {
|
||||
items := make([]models.Skill, 0)
|
||||
for _, skill := range s.skills {
|
||||
if skill.UserID == userID {
|
||||
items = append(items, *skill)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ListAllSkills() ([]models.Skill, error) {
|
||||
items := make([]models.Skill, 0, len(s.skills))
|
||||
for _, skill := range s.skills {
|
||||
items = append(items, *skill)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetSkillByID(id int) (*models.Skill, error) {
|
||||
if skill, ok := s.skills[id]; ok {
|
||||
copy := *skill
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetSkillByUserKey(userID int, skillKey string) (*models.Skill, error) {
|
||||
for _, skill := range s.skills {
|
||||
if skill.UserID == userID && skill.SkillKey == skillKey && skill.Status == skillStatusActive {
|
||||
copy := *skill
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) CreateSkill(*models.Skill) error { return nil }
|
||||
func (s *skillRepoStub) UpdateSkill(skill *models.Skill) error {
|
||||
if s.skills == nil {
|
||||
s.skills = map[int]*models.Skill{}
|
||||
}
|
||||
copy := *skill
|
||||
s.skills[skill.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) DeleteSkill(int) error {
|
||||
s.hardDeleteCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetBlobByContentHash(string) (*models.SkillBlob, error) { return nil, nil }
|
||||
|
||||
func (s *skillRepoStub) GetBlobByID(id int) (*models.SkillBlob, error) {
|
||||
if blob, ok := s.blobs[id]; ok {
|
||||
copy := *blob
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) CreateBlob(blob *models.SkillBlob) error {
|
||||
if s.blobs == nil {
|
||||
s.blobs = map[int]*models.SkillBlob{}
|
||||
}
|
||||
if blob.ID == 0 {
|
||||
blob.ID = len(s.blobs) + 1
|
||||
}
|
||||
copy := *blob
|
||||
s.blobs[blob.ID] = ©
|
||||
return nil
|
||||
}
|
||||
func (s *skillRepoStub) UpdateBlob(blob *models.SkillBlob) error {
|
||||
if s.blobs == nil {
|
||||
s.blobs = map[int]*models.SkillBlob{}
|
||||
}
|
||||
copy := *blob
|
||||
s.blobs[blob.ID] = ©
|
||||
return nil
|
||||
}
|
||||
func (s *skillRepoStub) ListVersionsBySkillID(int) ([]models.SkillVersion, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetVersionByID(id int) (*models.SkillVersion, error) {
|
||||
if version, ok := s.versions[id]; ok {
|
||||
copy := *version
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetVersionBySkillAndBlob(int, int) (*models.SkillVersion, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetLatestVersionBySkillID(int) (*models.SkillVersion, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) CreateVersion(*models.SkillVersion) error { return nil }
|
||||
|
||||
func (s *skillRepoStub) UpdateVersion(version *models.SkillVersion) error {
|
||||
if s.versions == nil {
|
||||
s.versions = map[int]*models.SkillVersion{}
|
||||
}
|
||||
copy := *version
|
||||
s.versions[version.ID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ListInstanceSkills(int) ([]models.InstanceSkill, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ListActiveInstanceSkillsBySkillID(skillID int) ([]models.InstanceSkill, error) {
|
||||
if s.instanceSkillsBySkillID != nil {
|
||||
if items, ok := s.instanceSkillsBySkillID[skillID]; ok {
|
||||
return filterActiveInstanceSkills(items), nil
|
||||
}
|
||||
}
|
||||
items := make([]models.InstanceSkill, 0)
|
||||
for _, item := range s.instanceSkills {
|
||||
if item.SkillID == skillID && item.Status != "removed" && item.Status != "missing" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func filterActiveInstanceSkills(items []models.InstanceSkill) []models.InstanceSkill {
|
||||
active := make([]models.InstanceSkill, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Status != "removed" && item.Status != "missing" {
|
||||
active = append(active, item)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetInstanceSkill(instanceID, skillID int) (*models.InstanceSkill, error) {
|
||||
for _, item := range s.instanceSkills {
|
||||
if item.InstanceID == instanceID && item.SkillID == skillID {
|
||||
copy := item
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *skillRepoStub) UpsertInstanceSkill(*models.InstanceSkill) error { return nil }
|
||||
func (s *skillRepoStub) MarkInstanceSkillRemoved(int, int, time.Time) error { return nil }
|
||||
func (s *skillRepoStub) MarkInstanceSkillRemovedBySkillKey(int, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (s *skillRepoStub) MarkInstanceSkillsRemovedByWorkspacePath(int, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (s *skillRepoStub) MarkMissingInstanceSkills(int, []int, time.Time) error { return nil }
|
||||
func (s *skillRepoStub) CreateScanResult(result *models.SkillScanResult) error {
|
||||
if result.ID == 0 {
|
||||
result.ID = 99
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *skillRepoStub) GetScanResultByID(int) (*models.SkillScanResult, error) { return nil, nil }
|
||||
func (s *skillRepoStub) ListScanResultsByBlobID(int) ([]models.SkillScanResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetLatestScanResultByBlobID(int) (*models.SkillScanResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) GetLatestScanResultBySkillID(int) (*models.SkillScanResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ListHubTags(bool) ([]models.SkillHubTag, error) { return nil, nil }
|
||||
|
||||
func (s *skillRepoStub) GetHubTagByID(id int) (*models.SkillHubTag, error) {
|
||||
if tag, ok := s.tags[id]; ok {
|
||||
copy := *tag
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ListHubTagsBySkillID(skillID int) ([]models.SkillHubTag, error) {
|
||||
tagIDs := s.tagAssignments[skillID]
|
||||
result := make([]models.SkillHubTag, 0, len(tagIDs))
|
||||
for _, tagID := range tagIDs {
|
||||
if tag, ok := s.tags[tagID]; ok {
|
||||
result = append(result, *tag)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ReplaceSkillTagAssignments(skillID int, tagIDs []int) error {
|
||||
if s.tagAssignments == nil {
|
||||
s.tagAssignments = map[int][]int{}
|
||||
}
|
||||
s.tagAssignments[skillID] = append([]int(nil), tagIDs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ListPublicHubSkills() ([]models.Skill, error) {
|
||||
items := make([]models.Skill, 0)
|
||||
for _, skill := range s.skills {
|
||||
if skill.Visibility == skillVisibilityPublic && skill.Status == skillStatusActive {
|
||||
items = append(items, *skill)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *skillRepoStub) ListSkillsForHubAdmin() ([]models.Skill, error) {
|
||||
return s.ListAllSkills()
|
||||
}
|
||||
|
||||
type hubTagRepoStub struct {
|
||||
tags map[int]*models.SkillHubTag
|
||||
}
|
||||
|
||||
func (s *hubTagRepoStub) ListSkillsByUser(int) ([]models.Skill, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) ListAllSkills() ([]models.Skill, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) GetSkillByID(int) (*models.Skill, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) GetSkillByUserKey(int, string) (*models.Skill, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) CreateSkill(*models.Skill) error { return nil }
|
||||
func (s *hubTagRepoStub) UpdateSkill(*models.Skill) error { return nil }
|
||||
func (s *hubTagRepoStub) DeleteSkill(int) error { return nil }
|
||||
func (s *hubTagRepoStub) GetBlobByContentHash(string) (*models.SkillBlob, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) GetBlobByID(int) (*models.SkillBlob, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) CreateBlob(*models.SkillBlob) error { return nil }
|
||||
func (s *hubTagRepoStub) UpdateBlob(*models.SkillBlob) error { return nil }
|
||||
func (s *hubTagRepoStub) ListVersionsBySkillID(int) ([]models.SkillVersion, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) GetVersionByID(int) (*models.SkillVersion, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) GetVersionBySkillAndBlob(int, int) (*models.SkillVersion, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *hubTagRepoStub) GetLatestVersionBySkillID(int) (*models.SkillVersion, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *hubTagRepoStub) CreateVersion(*models.SkillVersion) error { return nil }
|
||||
func (s *hubTagRepoStub) UpdateVersion(*models.SkillVersion) error { return nil }
|
||||
func (s *hubTagRepoStub) ListInstanceSkills(int) ([]models.InstanceSkill, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *hubTagRepoStub) ListActiveInstanceSkillsBySkillID(int) ([]models.InstanceSkill, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *hubTagRepoStub) GetInstanceSkill(int, int) (*models.InstanceSkill, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) UpsertInstanceSkill(*models.InstanceSkill) error { return nil }
|
||||
func (s *hubTagRepoStub) MarkInstanceSkillRemoved(int, int, time.Time) error { return nil }
|
||||
func (s *hubTagRepoStub) MarkInstanceSkillRemovedBySkillKey(int, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (s *hubTagRepoStub) MarkInstanceSkillsRemovedByWorkspacePath(int, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (s *hubTagRepoStub) MarkMissingInstanceSkills(int, []int, time.Time) error { return nil }
|
||||
func (s *hubTagRepoStub) CreateScanResult(*models.SkillScanResult) error { return nil }
|
||||
func (s *hubTagRepoStub) GetScanResultByID(int) (*models.SkillScanResult, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) ListScanResultsByBlobID(int) ([]models.SkillScanResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *hubTagRepoStub) GetLatestScanResultByBlobID(int) (*models.SkillScanResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *hubTagRepoStub) GetLatestScanResultBySkillID(int) (*models.SkillScanResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *hubTagRepoStub) ListHubTags(bool) ([]models.SkillHubTag, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) GetHubTagByID(id int) (*models.SkillHubTag, error) {
|
||||
if tag, ok := s.tags[id]; ok {
|
||||
return tag, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *hubTagRepoStub) ListHubTagsBySkillID(int) ([]models.SkillHubTag, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) ReplaceSkillTagAssignments(int, []int) error { return nil }
|
||||
func (s *hubTagRepoStub) ListPublicHubSkills() ([]models.Skill, error) { return nil, nil }
|
||||
func (s *hubTagRepoStub) ListSkillsForHubAdmin() ([]models.Skill, error) { return nil, nil }
|
||||
@@ -0,0 +1,808 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
skillVisibilityPrivate = "private"
|
||||
skillVisibilityPublic = "public"
|
||||
)
|
||||
|
||||
type SkillHubTagPayload struct {
|
||||
ID int `json:"id"`
|
||||
TagKey string `json:"tag_key"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
AdminOnly bool `json:"admin_only"`
|
||||
}
|
||||
|
||||
type SkillHubCatalogQuery struct {
|
||||
TagKeys []string
|
||||
Search string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type SkillHubCatalogResponse struct {
|
||||
Items []SkillPayload `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type PublishSkillHubRequest struct {
|
||||
TagIDs []int `json:"tag_ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
type UpdateSkillHubTagsRequest struct {
|
||||
TagIDs []int `json:"tag_ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
type InstallHubSkillRequest struct {
|
||||
InstanceID int `json:"instance_id" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
func isAdminRole(role string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(role), "admin")
|
||||
}
|
||||
|
||||
func (s *skillService) skillBlobForPublish(skill *models.Skill) (*models.SkillBlob, error) {
|
||||
if skill == nil || skill.CurrentVersionID == nil {
|
||||
return nil, fmt.Errorf("skill has no version")
|
||||
}
|
||||
version, err := s.repo.GetVersionByID(*skill.CurrentVersionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if version == nil {
|
||||
return nil, fmt.Errorf("skill has no version")
|
||||
}
|
||||
blob, err := s.repo.GetBlobByID(version.BlobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blob == nil {
|
||||
return nil, fmt.Errorf("skill blob not found")
|
||||
}
|
||||
return blob, nil
|
||||
}
|
||||
|
||||
func isHubPublishableBlob(blob *models.SkillBlob) bool {
|
||||
if blob == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
|
||||
return false
|
||||
}
|
||||
risk := strings.ToLower(strings.TrimSpace(blob.RiskLevel))
|
||||
if risk != skillRiskNone && risk != skillRiskLow {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(blob.ObjectKey) == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *skillService) isHubPublishable(skill *models.Skill, blob *models.SkillBlob) bool {
|
||||
if skill == nil || blob == nil || isDeletedSkill(skill) {
|
||||
return false
|
||||
}
|
||||
if !isUserManagedSkill(*skill) && !strings.EqualFold(strings.TrimSpace(skill.SourceType), skillSourceDiscovered) {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(skill.Status), "active") {
|
||||
return false
|
||||
}
|
||||
return isHubPublishableBlob(blob)
|
||||
}
|
||||
|
||||
func (s *skillService) CanDownloadSkill(actorUserID int, actorRole string, skill *models.Skill) bool {
|
||||
if skill == nil || !isUserManagedSkill(*skill) {
|
||||
return false
|
||||
}
|
||||
return s.CanViewSkill(actorUserID, actorRole, skill)
|
||||
}
|
||||
|
||||
func (s *skillService) CanViewSkill(actorUserID int, actorRole string, skill *models.Skill) bool {
|
||||
if skill == nil || isDeletedSkill(skill) {
|
||||
return false
|
||||
}
|
||||
if isAdminRole(actorRole) {
|
||||
return isUserManagedSkill(*skill) || strings.EqualFold(skill.SourceType, skillSourceDiscovered)
|
||||
}
|
||||
if skill.UserID == actorUserID {
|
||||
return isUserManagedSkill(*skill) || strings.EqualFold(skill.SourceType, skillSourceDiscovered)
|
||||
}
|
||||
return isUserManagedSkill(*skill) && strings.EqualFold(strings.TrimSpace(skill.Visibility), skillVisibilityPublic)
|
||||
}
|
||||
|
||||
func (s *skillService) CanAttachSkill(actorUserID int, actorRole string, skill *models.Skill, instance *models.Instance) bool {
|
||||
if skill == nil || instance == nil {
|
||||
return false
|
||||
}
|
||||
if !isUserManagedSkill(*skill) {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(skill.Status), "active") {
|
||||
return false
|
||||
}
|
||||
if skill.RiskLevel == skillRiskMedium || skill.RiskLevel == skillRiskHigh {
|
||||
return false
|
||||
}
|
||||
if isAdminRole(actorRole) {
|
||||
return true
|
||||
}
|
||||
if instance.UserID != actorUserID {
|
||||
return false
|
||||
}
|
||||
if skill.UserID == actorUserID {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(skill.Visibility), skillVisibilityPublic)
|
||||
}
|
||||
|
||||
func (s *skillService) hubTagsToPayload(tags []models.SkillHubTag) []SkillHubTagPayload {
|
||||
result := make([]SkillHubTagPayload, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
result = append(result, SkillHubTagPayload{
|
||||
ID: tag.ID,
|
||||
TagKey: tag.TagKey,
|
||||
Name: tag.Name,
|
||||
Description: tag.Description,
|
||||
SortOrder: tag.SortOrder,
|
||||
AdminOnly: tag.AdminOnly,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *skillService) liteInstanceForSkill(skillID int) *models.Instance {
|
||||
if s == nil || s.repo == nil || s.instanceRepo == nil || skillID <= 0 {
|
||||
return nil
|
||||
}
|
||||
items, err := s.repo.ListActiveInstanceSkillsBySkillID(skillID)
|
||||
if err != nil || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, item := range items {
|
||||
instance, err := s.instanceRepo.GetByID(item.InstanceID)
|
||||
if err != nil || instance == nil {
|
||||
continue
|
||||
}
|
||||
if isLiteRuntimeInstance(instance) {
|
||||
return instance
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillService) enrichSkillPayload(payload *SkillPayload, skill models.Skill, instance *models.Instance) error {
|
||||
if instance == nil {
|
||||
instance = s.liteInstanceForSkill(skill.ID)
|
||||
}
|
||||
tags, err := s.repo.ListHubTagsBySkillID(skill.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload.Visibility = skill.Visibility
|
||||
if strings.TrimSpace(payload.Visibility) == "" {
|
||||
payload.Visibility = skillVisibilityPrivate
|
||||
}
|
||||
payload.PublishedAt = skill.PublishedAt
|
||||
payload.PublishedBy = skill.PublishedBy
|
||||
payload.Tags = s.hubTagsToPayload(tags)
|
||||
|
||||
blob, blobErr := s.skillBlobForPublish(&skill)
|
||||
if blobErr == nil {
|
||||
payload.Publishable = s.isHubPublishable(&skill, blob)
|
||||
payload.ScanStatus = blob.ScanStatus
|
||||
} else {
|
||||
payload.Publishable = false
|
||||
}
|
||||
skipAgentCollectFailure := instance != nil && isLiteRuntimeInstance(instance)
|
||||
payload.PublishBlockedReason = s.publishBlockedReasonForSkill(&skill, blob, blobErr, payload.Publishable, skipAgentCollectFailure)
|
||||
if s.materializeService != nil && blobErr == nil && blob != nil && strings.TrimSpace(blob.ObjectKey) == "" {
|
||||
if status, materializeErr := s.materializeService.GetObservedStatus(skill.ID, blob); status != nil {
|
||||
payload.PackageMaterializeStatus = status
|
||||
payload.PackageMaterializeError = materializeErr
|
||||
}
|
||||
}
|
||||
if collectErr := s.resolvePackageCollectError(skill.ID, blob, blobErr, skipAgentCollectFailure); collectErr != nil {
|
||||
payload.PackageCollectError = collectErr
|
||||
}
|
||||
|
||||
if s.userRepo != nil {
|
||||
owner, err := s.userRepo.GetByID(skill.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != nil {
|
||||
payload.OwnerUsername = &owner.Username
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateCollectError(value string, maxLen int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if maxLen <= 0 || len(value) <= maxLen {
|
||||
return value
|
||||
}
|
||||
return value[:maxLen] + "..."
|
||||
}
|
||||
|
||||
func (s *skillService) resolvePackageCollectError(skillID int, blob *models.SkillBlob, blobErr error, skipAgentCollectFailure bool) *string {
|
||||
if blobErr != nil || blob == nil || strings.TrimSpace(blob.ObjectKey) != "" {
|
||||
return nil
|
||||
}
|
||||
if s.materializeService != nil {
|
||||
if _, materializeErr := s.materializeService.GetObservedStatus(skillID, blob); materializeErr != nil {
|
||||
return materializeErr
|
||||
}
|
||||
job, err := s.materializeService.FindLatestBySkillID(skillID)
|
||||
if err == nil && job != nil && job.LastError != nil && strings.TrimSpace(*job.LastError) != "" {
|
||||
summary := truncateCollectError(*job.LastError, 512)
|
||||
if summary != "" {
|
||||
return &summary
|
||||
}
|
||||
}
|
||||
}
|
||||
if skipAgentCollectFailure {
|
||||
return nil
|
||||
}
|
||||
cmd, err := s.latestCollectPackageFailure(skillID)
|
||||
if err != nil || cmd == nil {
|
||||
return nil
|
||||
}
|
||||
if cmd.ErrorMessage == nil {
|
||||
return nil
|
||||
}
|
||||
summary := truncateCollectError(*cmd.ErrorMessage, 512)
|
||||
if summary == "" {
|
||||
return nil
|
||||
}
|
||||
return &summary
|
||||
}
|
||||
|
||||
func (s *skillService) latestCollectPackageFailure(skillID int) (*models.InstanceCommand, error) {
|
||||
if s.commandRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.commandRepo.FindLatestFailedCollectSkillPackage(formatExternalSkillID(skillID))
|
||||
}
|
||||
|
||||
func (s *skillService) publishBlockedReasonForSkill(skill *models.Skill, blob *models.SkillBlob, blobErr error, publishable bool, skipAgentCollectFailure bool) *string {
|
||||
if publishable || skill == nil {
|
||||
return nil
|
||||
}
|
||||
reason := func(value string) *string {
|
||||
return &value
|
||||
}
|
||||
if isDeletedSkill(skill) {
|
||||
return reason("skill_deleted")
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(skill.Status), skillStatusActive) {
|
||||
return reason("skill_inactive")
|
||||
}
|
||||
if blobErr != nil || blob == nil {
|
||||
return reason("skill_package_pending")
|
||||
}
|
||||
if strings.TrimSpace(blob.ObjectKey) == "" {
|
||||
if s.materializeService != nil {
|
||||
if job, err := s.materializeService.FindLatestBySkillID(skill.ID); err == nil && job != nil {
|
||||
if blocked := materializeBlockedReason(job); blocked != nil {
|
||||
return blocked
|
||||
}
|
||||
return reason("skill_package_pending")
|
||||
}
|
||||
}
|
||||
if skipAgentCollectFailure {
|
||||
return reason("skill_package_pending")
|
||||
}
|
||||
if cmd, err := s.latestCollectPackageFailure(skill.ID); err == nil && cmd != nil {
|
||||
return reason("skill_package_collect_failed")
|
||||
}
|
||||
return reason("skill_package_pending")
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "failed") {
|
||||
return reason("skill_scan_failed")
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
|
||||
return reason("skill_not_scanned")
|
||||
}
|
||||
risk := strings.ToLower(strings.TrimSpace(blob.RiskLevel))
|
||||
if risk != skillRiskNone && risk != skillRiskLow {
|
||||
return reason("skill_risk_blocked")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillService) validateHubTagSelection(actorRole string, tagIDs []int) error {
|
||||
if len(tagIDs) == 0 {
|
||||
return fmt.Errorf("skill_tags_required")
|
||||
}
|
||||
hasPublicTag := false
|
||||
for _, tagID := range tagIDs {
|
||||
tag, err := s.repo.GetHubTagByID(tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag == nil {
|
||||
return fmt.Errorf("skill hub tag not found")
|
||||
}
|
||||
if tag.AdminOnly && !isAdminRole(actorRole) {
|
||||
return fmt.Errorf("access denied")
|
||||
}
|
||||
if !tag.AdminOnly {
|
||||
hasPublicTag = true
|
||||
}
|
||||
}
|
||||
if !hasPublicTag {
|
||||
return fmt.Errorf("skill_tags_required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillService) ListHubTags(actorRole string) ([]SkillHubTagPayload, error) {
|
||||
tags, err := s.repo.ListHubTags(isAdminRole(actorRole))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.hubTagsToPayload(tags), nil
|
||||
}
|
||||
|
||||
func (s *skillService) ListHubCatalog(_ int, _ string, query SkillHubCatalogQuery) (*SkillHubCatalogResponse, error) {
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize <= 0 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
if query.PageSize > 1000 {
|
||||
query.PageSize = 1000
|
||||
}
|
||||
|
||||
items, err := s.repo.ListPublicHubSkills()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tagKeySet := map[string]struct{}{}
|
||||
for _, key := range query.TagKeys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
tagKeySet[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
search := strings.ToLower(strings.TrimSpace(query.Search))
|
||||
|
||||
filtered := make([]SkillPayload, 0, len(items))
|
||||
for _, item := range items {
|
||||
blob, blobErr := s.skillBlobForPublish(&item)
|
||||
if blobErr != nil || !s.isHubPublishable(&item, blob) {
|
||||
continue
|
||||
}
|
||||
if len(tagKeySet) > 0 {
|
||||
tags, err := s.repo.ListHubTagsBySkillID(item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matched := false
|
||||
for _, tag := range tags {
|
||||
if _, ok := tagKeySet[tag.TagKey]; ok {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{item.Name, item.SkillKey, derefString(item.Description)}, " "))
|
||||
if !strings.Contains(haystack, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
payload, err := s.toSkillPayload(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enrichSkillPayload(payload, item, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered = append(filtered, *payload)
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
start := (query.Page - 1) * query.PageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + query.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
pageItems := filtered[start:end]
|
||||
totalPages := total / query.PageSize
|
||||
if total%query.PageSize != 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return &SkillHubCatalogResponse{
|
||||
Items: pageItems,
|
||||
Total: total,
|
||||
Page: query.Page,
|
||||
PageSize: query.PageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *skillService) ListMyHubSkills(userID int) ([]SkillPayload, error) {
|
||||
items, err := s.repo.ListSkillsByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make([]models.Skill, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDeletedSkill(&item) {
|
||||
continue
|
||||
}
|
||||
if isUserManagedSkill(item) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
result := make([]SkillPayload, 0, len(filtered))
|
||||
for _, item := range filtered {
|
||||
payload, err := s.toSkillPayload(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enrichSkillPayload(payload, item, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *payload)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *skillService) ListAllHubSkillsAdmin() ([]SkillPayload, error) {
|
||||
items, err := s.repo.ListSkillsForHubAdmin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]SkillPayload, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDeletedSkill(&item) {
|
||||
continue
|
||||
}
|
||||
payload, err := s.toSkillPayload(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enrichSkillPayload(payload, item, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, *payload)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *skillService) GetSkillHubDetail(actorUserID int, actorRole string, skillID int) (*SkillPayload, error) {
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill == nil || !s.CanViewSkill(actorUserID, actorRole, skill) {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
payload, err := s.toSkillPayload(*skill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enrichSkillPayload(payload, *skill, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *skillService) PublishToHub(actorUserID int, actorRole string, skillID int, tagIDs []int) (*SkillPayload, error) {
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill == nil || isDeletedSkill(skill) {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
if skill.UserID != actorUserID && !isAdminRole(actorRole) {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
if err := s.validateHubTagSelection(actorRole, tagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blob, err := s.skillBlobForPublish(skill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(blob.ObjectKey) == "" {
|
||||
return nil, fmt.Errorf("skill_package_pending")
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
|
||||
return nil, fmt.Errorf("skill_not_scanned")
|
||||
}
|
||||
if !isHubPublishableBlob(blob) {
|
||||
return nil, fmt.Errorf("skill_risk_blocked")
|
||||
}
|
||||
if strings.EqualFold(skill.SourceType, skillSourceDiscovered) {
|
||||
skill.SourceType = skillSourceUploaded
|
||||
}
|
||||
if err := s.repo.ReplaceSkillTagAssignments(skillID, tagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
skill.Visibility = skillVisibilityPublic
|
||||
skill.PublishedAt = &now
|
||||
skill.PublishedBy = &actorUserID
|
||||
skill.UpdatedAt = now
|
||||
if err := s.repo.UpdateSkill(skill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSkillHubDetail(actorUserID, actorRole, skillID)
|
||||
}
|
||||
|
||||
func (s *skillService) UnpublishFromHub(actorUserID int, actorRole string, skillID int) (*SkillPayload, error) {
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill == nil {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
if skill.UserID != actorUserID && !isAdminRole(actorRole) {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
skill.Visibility = skillVisibilityPrivate
|
||||
skill.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpdateSkill(skill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSkillHubDetail(actorUserID, actorRole, skillID)
|
||||
}
|
||||
|
||||
func (s *skillService) UpdateHubTags(actorUserID int, actorRole string, skillID int, tagIDs []int) (*SkillPayload, error) {
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill == nil {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
if skill.UserID != actorUserID && !isAdminRole(actorRole) {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(skill.Visibility), skillVisibilityPublic) {
|
||||
return nil, fmt.Errorf("skill is not published to hub")
|
||||
}
|
||||
if err := s.validateHubTagSelection(actorRole, tagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.ReplaceSkillTagAssignments(skillID, tagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
skill.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpdateSkill(skill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSkillHubDetail(actorUserID, actorRole, skillID)
|
||||
}
|
||||
|
||||
func (s *skillService) InstallHubSkill(actorUserID int, actorRole string, skillID, instanceID int) (*InstanceSkillPayload, error) {
|
||||
return s.AttachSkillToInstance(actorUserID, actorRole, instanceID, skillID)
|
||||
}
|
||||
|
||||
func (s *skillService) ImportInstanceSkillToLibrary(actorUserID int, actorRole string, instanceID, skillID int) (*SkillPayload, error) {
|
||||
instance, err := s.instanceRepo.GetByID(instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if instance == nil {
|
||||
return nil, fmt.Errorf("instance not found")
|
||||
}
|
||||
if !isAdminRole(actorRole) && instance.UserID != actorUserID {
|
||||
return nil, fmt.Errorf("access denied")
|
||||
}
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill == nil || skill.UserID != instance.UserID || isDeletedSkill(skill) {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
instanceSkill, err := s.repo.GetInstanceSkill(instanceID, skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if instanceSkill == nil || instanceSkill.Status == "removed" {
|
||||
return nil, fmt.Errorf("skill not found on instance")
|
||||
}
|
||||
if isUserManagedSkill(*skill) {
|
||||
blob, blobErr := s.skillBlobForPublish(skill)
|
||||
if blobErr == nil && strings.TrimSpace(blob.ObjectKey) != "" && strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
|
||||
return s.GetSkillHubDetail(actorUserID, actorRole, skillID)
|
||||
}
|
||||
}
|
||||
if err := s.requestSkillPackageCollection(instanceID, skill, instanceSkill, fmt.Sprintf("import-%d-%d", instanceID, skillID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blob, err := s.skillBlobForPublish(skill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := s.storage.GetObject(context.Background(), blob.ObjectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureBlobObject(context.Background(), blob, content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blob.LastScanResultID == nil || !strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
|
||||
if err := s.recordScanFromStoredBlob(blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
skill, err = s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill == nil {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
blob, err = s.repo.GetBlobByID(blob.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blob != nil {
|
||||
skill.RiskLevel = blob.RiskLevel
|
||||
skill.LastScannedAt = blob.LastScannedAt
|
||||
skill.LastScanResultID = blob.LastScanResultID
|
||||
}
|
||||
if err := s.promoteSkillToUploadedLibrary(skill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSkillHubDetail(actorUserID, actorRole, skillID)
|
||||
}
|
||||
|
||||
func (s *skillService) RetrySkillPackageCollection(actorUserID int, actorRole string, instanceID, skillID int) error {
|
||||
instance, err := s.instanceRepo.GetByID(instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if instance == nil {
|
||||
return fmt.Errorf("instance not found")
|
||||
}
|
||||
if !isAdminRole(actorRole) && instance.UserID != actorUserID {
|
||||
return fmt.Errorf("access denied")
|
||||
}
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if skill == nil || skill.UserID != instance.UserID || isDeletedSkill(skill) {
|
||||
return fmt.Errorf("skill not found")
|
||||
}
|
||||
instanceSkill, err := s.repo.GetInstanceSkill(instanceID, skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if instanceSkill == nil || instanceSkill.Status == "removed" {
|
||||
return fmt.Errorf("skill not found on instance")
|
||||
}
|
||||
blob, blobErr := s.skillBlobForPublish(skill)
|
||||
if blobErr == nil && strings.TrimSpace(blob.ObjectKey) != "" && strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
|
||||
return nil
|
||||
}
|
||||
if isLiteRuntimeInstance(instance) && s.materializeService != nil {
|
||||
if job, findErr := s.materializeService.FindLatestBySkillID(skillID); findErr == nil && job != nil && strings.EqualFold(strings.TrimSpace(job.Status), MaterializeJobStatusFailed) {
|
||||
_ = s.materializeService.RetryJob(skillID)
|
||||
}
|
||||
}
|
||||
return s.requestSkillPackageCollection(instanceID, skill, instanceSkill, fmt.Sprintf("retry-%d-%d-%d", instanceID, skillID, time.Now().Unix()))
|
||||
}
|
||||
|
||||
func (s *skillService) PublishFromInstance(actorUserID int, actorRole string, instanceID, skillID int, tagIDs []int) (*SkillPayload, error) {
|
||||
instance, err := s.instanceRepo.GetByID(instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if instance == nil {
|
||||
return nil, fmt.Errorf("instance not found")
|
||||
}
|
||||
if !isAdminRole(actorRole) && instance.UserID != actorUserID {
|
||||
return nil, fmt.Errorf("access denied")
|
||||
}
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill == nil || skill.UserID != instance.UserID {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
if !isUserManagedSkill(*skill) {
|
||||
return nil, fmt.Errorf("skill_not_in_library")
|
||||
}
|
||||
instanceSkill, err := s.repo.GetInstanceSkill(instanceID, skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if instanceSkill == nil || instanceSkill.Status == "removed" {
|
||||
return nil, fmt.Errorf("skill not found on instance")
|
||||
}
|
||||
if err := s.requestSkillPackageCollection(instanceID, skill, instanceSkill, fmt.Sprintf("publish-%d-%d", instanceID, skillID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.PublishToHub(actorUserID, actorRole, skillID, tagIDs)
|
||||
}
|
||||
|
||||
func (s *skillService) ListAttachableSkills(actorUserID int, actorRole string) ([]SkillPayload, error) {
|
||||
result := make([]SkillPayload, 0)
|
||||
seen := map[int]struct{}{}
|
||||
|
||||
mine, err := s.ListMyHubSkills(actorUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range mine {
|
||||
if strings.EqualFold(item.SourceType, skillSourceDiscovered) {
|
||||
continue
|
||||
}
|
||||
if item.Status != "active" || item.RiskLevel == skillRiskMedium || item.RiskLevel == skillRiskHigh {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
catalog, err := s.ListHubCatalog(actorUserID, actorRole, SkillHubCatalogQuery{Page: 1, PageSize: 1000})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range catalog.Items {
|
||||
if item.UserID == actorUserID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *skillService) ImportHubArchive(ctx context.Context, userID int, fileHeader *multipart.FileHeader) ([]SkillPayload, error) {
|
||||
items, err := s.ImportHubArchiveWithDecisions(ctx, userID, fileHeader, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]SkillPayload, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, item.Skill)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
func TestIsHubPublishableBlob(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
blob *models.SkillBlob
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "completed none risk with object key",
|
||||
blob: &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "user/demo/hash.zip"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "completed low risk",
|
||||
blob: &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskLow, ObjectKey: "key"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "medium risk blocked",
|
||||
blob: &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskMedium, ObjectKey: "key"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "pending scan blocked",
|
||||
blob: &models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskNone, ObjectKey: "key"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "missing object key blocked",
|
||||
blob: &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: ""},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isHubPublishableBlob(tc.blob); got != tc.want {
|
||||
t.Fatalf("isHubPublishableBlob() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMyHubSkillsExcludesDiscoveredSkills(t *testing.T) {
|
||||
svc := &skillService{
|
||||
repo: &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {ID: 1, UserID: 1, SkillKey: "dogfood", Name: "dogfood", Status: skillStatusActive, SourceType: skillSourceUploaded},
|
||||
2: {ID: 2, UserID: 1, SkillKey: "software-development-spike", Name: "software-development/spike", Status: skillStatusActive, SourceType: skillSourceDiscovered},
|
||||
},
|
||||
},
|
||||
}
|
||||
items, err := svc.ListMyHubSkills(1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMyHubSkills() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 uploaded skill only", len(items))
|
||||
}
|
||||
if items[0].SkillKey != "dogfood" {
|
||||
t.Fatalf("SkillKey = %q, want dogfood", items[0].SkillKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAttachSkillRules(t *testing.T) {
|
||||
svc := &skillService{}
|
||||
privateSkill := &models.Skill{UserID: 1, SourceType: skillSourceUploaded, Status: "active", Visibility: skillVisibilityPrivate, RiskLevel: skillRiskLow}
|
||||
publicSkill := &models.Skill{UserID: 2, SourceType: skillSourceUploaded, Status: "active", Visibility: skillVisibilityPublic, RiskLevel: skillRiskLow}
|
||||
ownInstance := &models.Instance{UserID: 1}
|
||||
otherInstance := &models.Instance{UserID: 3}
|
||||
|
||||
if !svc.CanAttachSkill(1, "user", privateSkill, ownInstance) {
|
||||
t.Fatal("owner should attach private skill to own instance")
|
||||
}
|
||||
if svc.CanAttachSkill(3, "user", privateSkill, otherInstance) {
|
||||
t.Fatal("other user must not attach private skill")
|
||||
}
|
||||
if !svc.CanAttachSkill(3, "user", publicSkill, otherInstance) {
|
||||
t.Fatal("user should attach public skill to own instance")
|
||||
}
|
||||
if svc.CanAttachSkill(3, "user", publicSkill, ownInstance) {
|
||||
t.Fatal("user must not attach public skill to someone else's instance")
|
||||
}
|
||||
if !svc.CanAttachSkill(99, "admin", privateSkill, otherInstance) {
|
||||
t.Fatal("admin should attach any skill to any instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanViewSkillRules(t *testing.T) {
|
||||
svc := &skillService{}
|
||||
privateSkill := &models.Skill{UserID: 1, SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate}
|
||||
publicSkill := &models.Skill{UserID: 2, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic}
|
||||
|
||||
if !svc.CanViewSkill(1, "user", privateSkill) {
|
||||
t.Fatal("owner should view private skill")
|
||||
}
|
||||
if svc.CanViewSkill(3, "user", privateSkill) {
|
||||
t.Fatal("other user must not view private skill")
|
||||
}
|
||||
if !svc.CanViewSkill(3, "user", publicSkill) {
|
||||
t.Fatal("user should view public skill")
|
||||
}
|
||||
if !svc.CanViewSkill(99, "admin", privateSkill) {
|
||||
t.Fatal("admin should view private skill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanDownloadSkillRules(t *testing.T) {
|
||||
svc := &skillService{}
|
||||
privateSkill := &models.Skill{UserID: 1, SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate}
|
||||
publicSkill := &models.Skill{UserID: 2, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic}
|
||||
discoveredSkill := &models.Skill{UserID: 1, SourceType: skillSourceDiscovered, Visibility: skillVisibilityPublic}
|
||||
|
||||
if !svc.CanDownloadSkill(1, "user", privateSkill) {
|
||||
t.Fatal("owner should download private uploaded skill")
|
||||
}
|
||||
if svc.CanDownloadSkill(3, "user", privateSkill) {
|
||||
t.Fatal("other user must not download private skill")
|
||||
}
|
||||
if !svc.CanDownloadSkill(3, "user", publicSkill) {
|
||||
t.Fatal("user should download public skill")
|
||||
}
|
||||
if svc.CanDownloadSkill(1, "user", discoveredSkill) {
|
||||
t.Fatal("discovered skill is not user-managed and must not download")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHubPublishableSkill(t *testing.T) {
|
||||
svc := &skillService{}
|
||||
blob := &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "user/demo/hash.zip"}
|
||||
activeSkill := &models.Skill{SourceType: skillSourceUploaded, Status: "active"}
|
||||
inactiveSkill := &models.Skill{SourceType: skillSourceUploaded, Status: "inactive"}
|
||||
|
||||
if !svc.isHubPublishable(activeSkill, blob) {
|
||||
t.Fatal("active uploaded skill with clean blob should be publishable")
|
||||
}
|
||||
if svc.isHubPublishable(inactiveSkill, blob) {
|
||||
t.Fatal("inactive skill must not be publishable")
|
||||
}
|
||||
if svc.isHubPublishable(activeSkill, &models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskNone, ObjectKey: "key"}) {
|
||||
t.Fatal("pending scan blob must block publish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHubTagSelection(t *testing.T) {
|
||||
svc := &skillService{repo: &hubTagRepoStub{
|
||||
tags: map[int]*models.SkillHubTag{
|
||||
1: {ID: 1, TagKey: "coding", Name: "Coding", AdminOnly: false},
|
||||
2: {ID: 2, TagKey: "featured", Name: "Featured", AdminOnly: true},
|
||||
},
|
||||
}}
|
||||
|
||||
if err := svc.validateHubTagSelection("user", nil); err == nil || err.Error() != "skill_tags_required" {
|
||||
t.Fatalf("empty tag list should require tags, got %v", err)
|
||||
}
|
||||
if err := svc.validateHubTagSelection("user", []int{2}); err == nil || err.Error() != "access denied" {
|
||||
t.Fatalf("user must not select admin-only tag alone, got %v", err)
|
||||
}
|
||||
if err := svc.validateHubTagSelection("user", []int{1}); err != nil {
|
||||
t.Fatalf("user with public tag should pass, got %v", err)
|
||||
}
|
||||
if err := svc.validateHubTagSelection("admin", []int{2}); err == nil || err.Error() != "skill_tags_required" {
|
||||
t.Fatalf("admin-only tag alone should still require a public tag, got %v", err)
|
||||
}
|
||||
if err := svc.validateHubTagSelection("admin", []int{1, 2}); err != nil {
|
||||
t.Fatalf("admin with mixed tags should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPublishTestStub(blob *models.SkillBlob) (*skillService, *skillRepoStub) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
||||
SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{blobID: blob},
|
||||
tags: map[int]*models.SkillHubTag{
|
||||
1: {ID: 1, TagKey: "coding", Name: "Coding", AdminOnly: false},
|
||||
},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
return &skillService{repo: stub}, stub
|
||||
}
|
||||
|
||||
func TestDeleteSkillSoftPreservesInstanceSkills(t *testing.T) {
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic},
|
||||
},
|
||||
tagAssignments: map[int][]int{1: {1}},
|
||||
instanceSkillsBySkillID: map[int][]models.InstanceSkill{
|
||||
1: {{ID: 99, InstanceID: 2, SkillID: 1, Status: "active"}},
|
||||
},
|
||||
}
|
||||
svc := &skillService{repo: stub}
|
||||
if err := svc.DeleteSkill(1, "user", 1); err != nil {
|
||||
t.Fatalf("DeleteSkill() error = %v", err)
|
||||
}
|
||||
if stub.hardDeleteCalled {
|
||||
t.Fatal("DeleteSkill must not hard-delete skill row")
|
||||
}
|
||||
if stub.skills[1].Status != skillStatusDeleted {
|
||||
t.Fatalf("expected soft-deleted status, got %q", stub.skills[1].Status)
|
||||
}
|
||||
if len(stub.tagAssignments[1]) != 0 {
|
||||
t.Fatalf("expected tag assignments cleared, got %v", stub.tagAssignments[1])
|
||||
}
|
||||
if len(stub.instanceSkillsBySkillID[1]) != 1 {
|
||||
t.Fatal("instance_skills records must remain after soft delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanViewSkillDeletedHidden(t *testing.T) {
|
||||
svc := &skillService{}
|
||||
deletedSkill := &models.Skill{UserID: 1, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic, Status: skillStatusDeleted}
|
||||
if svc.CanViewSkill(3, "user", deletedSkill) {
|
||||
t.Fatal("deleted public skill must not be viewable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishToHubRejectsPendingScan(t *testing.T) {
|
||||
svc, _ := newPublishTestStub(&models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskNone, ObjectKey: "key.zip"})
|
||||
_, err := svc.PublishToHub(1, "user", 1, []int{1})
|
||||
if err == nil || err.Error() != "skill_not_scanned" {
|
||||
t.Fatalf("expected skill_not_scanned, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishToHubRejectsMediumRisk(t *testing.T) {
|
||||
svc, _ := newPublishTestStub(&models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskMedium, ObjectKey: "key.zip"})
|
||||
_, err := svc.PublishToHub(1, "user", 1, []int{1})
|
||||
if err == nil || err.Error() != "skill_risk_blocked" {
|
||||
t.Fatalf("expected skill_risk_blocked, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishToHubRejectsEmptyTags(t *testing.T) {
|
||||
svc, _ := newPublishTestStub(&models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"})
|
||||
_, err := svc.PublishToHub(1, "user", 1, nil)
|
||||
if err == nil || err.Error() != "skill_tags_required" {
|
||||
t.Fatalf("expected skill_tags_required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishToHubAllowsAdminForOtherUsersSkill(t *testing.T) {
|
||||
svc, _ := newPublishTestStub(&models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"})
|
||||
item, err := svc.PublishToHub(99, "admin", 1, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("PublishToHub() error = %v", err)
|
||||
}
|
||||
if item == nil || !strings.EqualFold(item.Visibility, skillVisibilityPublic) {
|
||||
t.Fatalf("expected admin publish to succeed, got %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpublishAllowsAdminForOtherUsersSkill(t *testing.T) {
|
||||
svc, stub := newPublishTestStub(&models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"})
|
||||
stub.skills[1].Visibility = skillVisibilityPublic
|
||||
item, err := svc.UnpublishFromHub(99, "admin", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("UnpublishFromHub() error = %v", err)
|
||||
}
|
||||
if item == nil || !strings.EqualFold(item.Visibility, skillVisibilityPrivate) {
|
||||
t.Fatalf("expected admin unpublish to succeed, got %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnpublishRemovesFromPublicCatalog(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
||||
SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{10: {ID: 10, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{blobID: {ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"}},
|
||||
}
|
||||
svc := &skillService{repo: stub}
|
||||
if _, err := svc.UnpublishFromHub(1, "user", 1); err != nil {
|
||||
t.Fatalf("UnpublishFromHub() error = %v", err)
|
||||
}
|
||||
if stub.skills[1].Visibility != skillVisibilityPrivate {
|
||||
t.Fatalf("expected private visibility after unpublish, got %q", stub.skills[1].Visibility)
|
||||
}
|
||||
publicSkills, err := stub.ListPublicHubSkills()
|
||||
if err != nil {
|
||||
t.Fatalf("ListPublicHubSkills() error = %v", err)
|
||||
}
|
||||
if len(publicSkills) != 0 {
|
||||
t.Fatalf("catalog source should be empty after unpublish, got %d items", len(publicSkills))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllHubSkillsAdminExcludesDeleted(t *testing.T) {
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic},
|
||||
2: {ID: 2, UserID: 1, SkillKey: "demo__deleted_2", Name: "Demo", Status: skillStatusDeleted, SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate},
|
||||
},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
svc := &skillService{repo: stub}
|
||||
items, err := svc.ListAllHubSkillsAdmin()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAllHubSkillsAdmin() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("ListAllHubSkillsAdmin() len = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].ID != 1 {
|
||||
t.Fatalf("ListAllHubSkillsAdmin() id = %d, want 1", items[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
type importTestInstanceRepo struct {
|
||||
instances map[int]*models.Instance
|
||||
}
|
||||
|
||||
func (r *importTestInstanceRepo) Create(*models.Instance) error { panic("not used") }
|
||||
func (r *importTestInstanceRepo) GetByID(id int) (*models.Instance, error) {
|
||||
if inst, ok := r.instances[id]; ok {
|
||||
copy := *inst
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (r *importTestInstanceRepo) GetByAccessToken(string) (*models.Instance, error) { panic("not used") }
|
||||
func (r *importTestInstanceRepo) GetByAgentBootstrapToken(string) (*models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) GetAll(int, int) ([]models.Instance, error) {
|
||||
items := make([]models.Instance, 0, len(r.instances))
|
||||
for _, inst := range r.instances {
|
||||
items = append(items, *inst)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (r *importTestInstanceRepo) CountAll() (int, error) { panic("not used") }
|
||||
func (r *importTestInstanceRepo) GetByUserID(int, int, int) ([]models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) CountByUserID(int) (int, error) { panic("not used") }
|
||||
func (r *importTestInstanceRepo) CountActiveByMode(context.Context, string) (int, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) ExistsByUserIDAndName(int, string) (bool, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) GetAllRunning() ([]models.Instance, error) { panic("not used") }
|
||||
func (r *importTestInstanceRepo) GetV2DesiredRunning(context.Context, int) ([]models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) GetV2Creating(context.Context, int) ([]models.Instance, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) UpdateRuntimeState(context.Context, int, string, int, *string) error {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) SetWorkspacePath(context.Context, int, string) error {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) UpdateWorkspaceUsage(context.Context, int, int64) error {
|
||||
panic("not used")
|
||||
}
|
||||
func (r *importTestInstanceRepo) Update(*models.Instance) error { panic("not used") }
|
||||
func (r *importTestInstanceRepo) Delete(int) error { panic("not used") }
|
||||
|
||||
type noopInstanceCommandService struct{}
|
||||
|
||||
func (n *noopInstanceCommandService) Create(int, *int, CreateInstanceCommandRequest) (*InstanceCommandPayload, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (n *noopInstanceCommandService) GetNextForAgent(*AgentSession) (*AgentCommandEnvelope, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (n *noopInstanceCommandService) MarkStarted(*AgentSession, int, *time.Time) error {
|
||||
panic("not used")
|
||||
}
|
||||
func (n *noopInstanceCommandService) MarkFinished(*AgentSession, int, AgentCommandFinishRequest) error {
|
||||
panic("not used")
|
||||
}
|
||||
func (n *noopInstanceCommandService) ListByInstanceID(int, int) ([]InstanceCommandPayload, error) {
|
||||
panic("not used")
|
||||
}
|
||||
|
||||
func TestPublishFromInstanceRejectsDiscoveredSkill(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
||||
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"}},
|
||||
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
_, err := svc.PublishFromInstance(1, "user", 1, 1, []int{1})
|
||||
if err == nil || err.Error() != "skill_not_in_library" {
|
||||
t.Fatalf("expected skill_not_in_library, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportInstanceSkillToLibraryPendingPackage(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
||||
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""}},
|
||||
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
||||
tagAssignments: map[int][]int{},
|
||||
tags: map[int]*models.SkillHubTag{
|
||||
1: {ID: 1, TagKey: "coding", Name: "Coding", AdminOnly: false},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
_, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
||||
if err == nil || err.Error() != "skill_package_pending" {
|
||||
t.Fatalf("expected skill_package_pending, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportInstanceSkillToLibraryRejectsNonOwner(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
||||
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""}},
|
||||
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
_, err := svc.ImportInstanceSkillToLibrary(2, "user", 1, 1)
|
||||
if err == nil || err.Error() != "access denied" {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportInstanceSkillToLibraryRejectsDeletedSkill(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusDeleted,
|
||||
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "discovered/1/demo.zip"}},
|
||||
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
_, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
||||
if err == nil || err.Error() != "skill not found" {
|
||||
t.Fatalf("expected skill not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportInstanceSkillToLibraryPromotesDiscoveredSkill(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
scanResultID := 99
|
||||
objectKey := "discovered/1/demo.zip"
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
||||
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID, SourceType: skillSourceDiscovered}},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
blobID: {
|
||||
ID: blobID, ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: objectKey,
|
||||
LastScanResultID: &scanResultID,
|
||||
},
|
||||
},
|
||||
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
storage := &importTestObjectStorage{objects: map[string][]byte{objectKey: []byte("fake-zip")}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}, storage: storage}
|
||||
_, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportInstanceSkillToLibrary() error = %v", err)
|
||||
}
|
||||
if stub.skills[1].SourceType != skillSourceUploaded {
|
||||
t.Fatalf("skill source_type = %q, want %q", stub.skills[1].SourceType, skillSourceUploaded)
|
||||
}
|
||||
if stub.versions[versionID].SourceType != skillSourceUploaded {
|
||||
t.Fatalf("version source_type = %q, want %q", stub.versions[versionID].SourceType, skillSourceUploaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportInstanceSkillToLibraryLiteMaterializesPackage(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
contentHash := "abc123def456789012345678901234"
|
||||
workspaceDir := "yuanbao"
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "yuanbao", Name: "yuanbao", Status: skillStatusActive,
|
||||
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID, SourceType: skillSourceDiscovered}},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
blobID: {ID: blobID, ContentHash: contentHash, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""},
|
||||
},
|
||||
instanceSkills: []models.InstanceSkill{{
|
||||
InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance",
|
||||
WorkspaceDir: &workspaceDir,
|
||||
}},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{
|
||||
1: {ID: 1, UserID: 1, InstanceMode: InstanceModeLite, RuntimeType: RuntimeBackendGateway},
|
||||
}}
|
||||
storage := &importTestObjectStorage{objects: map[string][]byte{}}
|
||||
cmdSvc := &capturingInstanceCommandService{}
|
||||
matSvc := NewSkillPackageMaterializeService(
|
||||
&materializeJobRepoStub{},
|
||||
stub,
|
||||
importLiteMaterializer{repo: stub, storage: storage, blobID: blobID},
|
||||
)
|
||||
svc := &skillService{
|
||||
repo: stub, instanceRepo: instRepo, commandService: cmdSvc, storage: storage, materializeService: matSvc,
|
||||
}
|
||||
_, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportInstanceSkillToLibrary() error = %v", err)
|
||||
}
|
||||
for _, req := range cmdSvc.created {
|
||||
if req.CommandType == InstanceCommandTypeCollectSkillPackage {
|
||||
t.Fatalf("unexpected collect_skill_package command: %#v", req)
|
||||
}
|
||||
}
|
||||
if stub.skills[1].SourceType != skillSourceUploaded {
|
||||
t.Fatalf("skill source_type = %q, want %q", stub.skills[1].SourceType, skillSourceUploaded)
|
||||
}
|
||||
blob := stub.blobs[blobID]
|
||||
if blob == nil || strings.TrimSpace(blob.ObjectKey) == "" {
|
||||
t.Fatalf("expected materialized object key, got %#v", blob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportInstanceSkillToLibraryIdempotentForUploaded(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
scanResultID := 99
|
||||
objectKey := "user/1/demo.zip"
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
||||
SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID, SourceType: skillSourceUploaded}},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
blobID: {
|
||||
ID: blobID, ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: objectKey,
|
||||
LastScanResultID: &scanResultID,
|
||||
},
|
||||
},
|
||||
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
storage := &importTestObjectStorage{objects: map[string][]byte{objectKey: []byte("fake-zip")}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}, storage: storage}
|
||||
payload, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportInstanceSkillToLibrary() error = %v", err)
|
||||
}
|
||||
if stub.skills[1].SourceType != skillSourceUploaded {
|
||||
t.Fatalf("skill source_type = %q, want %q", stub.skills[1].SourceType, skillSourceUploaded)
|
||||
}
|
||||
if payload == nil || payload.SourceType != skillSourceUploaded {
|
||||
t.Fatalf("payload source_type = %v, want %q", payload, skillSourceUploaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAgentSkillsCreatesDiscoveredSkillWithPrivateVisibility(t *testing.T) {
|
||||
stub := &capturingSkillRepoStub{
|
||||
skillRepoStub: skillRepoStub{
|
||||
skills: map[int]*models.Skill{},
|
||||
blobs: map[int]*models.SkillBlob{},
|
||||
versions: map[int]*models.SkillVersion{},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{
|
||||
Skills: []AgentSkillRecord{{
|
||||
Identifier: "weather",
|
||||
ContentMD5: "abc123def456789012345678901234",
|
||||
Source: "discovered_in_instance",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SyncAgentSkills() error = %v", err)
|
||||
}
|
||||
if len(stub.createdSkills) != 1 {
|
||||
t.Fatalf("created %d skills, want 1", len(stub.createdSkills))
|
||||
}
|
||||
if stub.createdSkills[0].Visibility != skillVisibilityPrivate {
|
||||
t.Fatalf("visibility = %q, want %q", stub.createdSkills[0].Visibility, skillVisibilityPrivate)
|
||||
}
|
||||
}
|
||||
|
||||
type capturingSkillRepoStub struct {
|
||||
skillRepoStub
|
||||
createdSkills []*models.Skill
|
||||
nextSkillID int
|
||||
markMissingCalls int
|
||||
lastMarkMissingActive []int
|
||||
}
|
||||
|
||||
func (s *capturingSkillRepoStub) CreateSkill(skill *models.Skill) error {
|
||||
s.nextSkillID++
|
||||
skill.ID = s.nextSkillID
|
||||
copy := *skill
|
||||
s.createdSkills = append(s.createdSkills, ©)
|
||||
if s.skills == nil {
|
||||
s.skills = map[int]*models.Skill{}
|
||||
}
|
||||
stored := *skill
|
||||
s.skills[skill.ID] = &stored
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *capturingSkillRepoStub) CreateBlob(blob *models.SkillBlob) error {
|
||||
if s.blobs == nil {
|
||||
s.blobs = map[int]*models.SkillBlob{}
|
||||
}
|
||||
s.nextSkillID++
|
||||
blob.ID = s.nextSkillID
|
||||
stored := *blob
|
||||
s.blobs[blob.ID] = &stored
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *capturingSkillRepoStub) CreateVersion(version *models.SkillVersion) error {
|
||||
if s.versions == nil {
|
||||
s.versions = map[int]*models.SkillVersion{}
|
||||
}
|
||||
s.nextSkillID++
|
||||
version.ID = s.nextSkillID
|
||||
stored := *version
|
||||
s.versions[version.ID] = &stored
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *capturingSkillRepoStub) GetBlobByContentHash(hash string) (*models.SkillBlob, error) {
|
||||
for _, blob := range s.blobs {
|
||||
if blob != nil && blob.ContentHash == hash {
|
||||
copy := *blob
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *capturingSkillRepoStub) GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error) {
|
||||
for _, version := range s.versions {
|
||||
if version != nil && version.SkillID == skillID && version.BlobID == blobID {
|
||||
copy := *version
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *capturingSkillRepoStub) UpsertInstanceSkill(item *models.InstanceSkill) error {
|
||||
copy := *item
|
||||
updated := false
|
||||
for i, existing := range s.instanceSkills {
|
||||
if existing.InstanceID == item.InstanceID && existing.SkillID == item.SkillID {
|
||||
s.instanceSkills[i] = copy
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
s.instanceSkills = append(s.instanceSkills, copy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *capturingSkillRepoStub) MarkMissingInstanceSkills(instanceID int, activeSkillIDs []int, observedAt time.Time) error {
|
||||
s.markMissingCalls++
|
||||
s.lastMarkMissingActive = append([]int(nil), activeSkillIDs...)
|
||||
active := map[int]struct{}{}
|
||||
for _, id := range activeSkillIDs {
|
||||
active[id] = struct{}{}
|
||||
}
|
||||
for i := range s.instanceSkills {
|
||||
item := &s.instanceSkills[i]
|
||||
if item.InstanceID != instanceID {
|
||||
continue
|
||||
}
|
||||
if _, ok := active[item.SkillID]; ok {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(item.Status, "removed") {
|
||||
continue
|
||||
}
|
||||
item.Status = "missing"
|
||||
item.RemovedAt = &observedAt
|
||||
item.UpdatedAt = observedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type importLiteMaterializer struct {
|
||||
repo *skillRepoStub
|
||||
storage *importTestObjectStorage
|
||||
blobID int
|
||||
}
|
||||
|
||||
func (m importLiteMaterializer) materializeSkillPackageFromWorkspace(_ context.Context, instanceID int, workspaceDir, contentHash string, _ int) (*models.SkillBlob, error) {
|
||||
objectKey := fmt.Sprintf("discovered/%d/%s/%s.zip", instanceID, workspaceDir, contentHash)
|
||||
if m.storage.objects == nil {
|
||||
m.storage.objects = map[string][]byte{}
|
||||
}
|
||||
m.storage.objects[objectKey] = []byte("fake-zip")
|
||||
scanID := 99
|
||||
blob := m.repo.blobs[m.blobID]
|
||||
blob.ObjectKey = objectKey
|
||||
blob.ScanStatus = "completed"
|
||||
blob.RiskLevel = skillRiskNone
|
||||
blob.LastScanResultID = &scanID
|
||||
m.repo.blobs[m.blobID] = blob
|
||||
return blob, nil
|
||||
}
|
||||
|
||||
func (importLiteMaterializer) syncSkillRecordFromBlob(int, *models.SkillBlob) error { return nil }
|
||||
|
||||
type importTestObjectStorage struct {
|
||||
objects map[string][]byte
|
||||
}
|
||||
|
||||
func (s *importTestObjectStorage) PutObject(_ context.Context, objectKey string, body []byte, _ string) error {
|
||||
if s.objects == nil {
|
||||
s.objects = map[string][]byte{}
|
||||
}
|
||||
s.objects[objectKey] = body
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *importTestObjectStorage) GetObject(_ context.Context, objectKey string) ([]byte, error) {
|
||||
if body, ok := s.objects[objectKey]; ok {
|
||||
return body, nil
|
||||
}
|
||||
return nil, fmt.Errorf("object not found: %s", objectKey)
|
||||
}
|
||||
|
||||
func TestDeleteSkillReleasesSkillKey(t *testing.T) {
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {ID: 1, UserID: 1, SkillKey: "weather", Name: "Weather", Status: skillStatusActive, SourceType: skillSourceUploaded},
|
||||
},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
svc := &skillService{repo: stub}
|
||||
if err := svc.DeleteSkill(1, "user", 1); err != nil {
|
||||
t.Fatalf("DeleteSkill() error = %v", err)
|
||||
}
|
||||
want := deletedSkillKey("weather", 1)
|
||||
if stub.skills[1].SkillKey != want {
|
||||
t.Fatalf("skill_key = %q, want %q", stub.skills[1].SkillKey, want)
|
||||
}
|
||||
active, err := stub.GetSkillByUserKey(1, "weather")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSkillByUserKey() error = %v", err)
|
||||
}
|
||||
if active != nil {
|
||||
t.Fatal("original skill_key should be released for re-import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAgentSkillsIncrementalDoesNotMarkMissing(t *testing.T) {
|
||||
contentHash := "abc123def456789012345678901234"
|
||||
versionID := 1
|
||||
stub := &capturingSkillRepoStub{
|
||||
skillRepoStub: skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
10: {
|
||||
ID: 10, UserID: 1, SkillKey: "weather", Name: "weather",
|
||||
SourceType: skillSourceDiscovered, Status: skillStatusActive,
|
||||
Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
1: {ID: 1, ContentHash: contentHash, ObjectKey: "discovered/weather.zip", ScanStatus: "completed"},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{
|
||||
1: {ID: 1, SkillID: 10, BlobID: 1, VersionNo: 1},
|
||||
},
|
||||
instanceSkills: []models.InstanceSkill{
|
||||
{InstanceID: 1, SkillID: 10, Status: "active", SourceType: "discovered_in_instance"},
|
||||
},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
|
||||
if err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{Mode: "incremental", Skills: nil}); err != nil {
|
||||
t.Fatalf("incremental SyncAgentSkills() error = %v", err)
|
||||
}
|
||||
if stub.markMissingCalls != 0 {
|
||||
t.Fatalf("markMissingCalls = %d, want 0 for incremental", stub.markMissingCalls)
|
||||
}
|
||||
if stub.instanceSkills[0].Status != "active" {
|
||||
t.Fatalf("status = %q, want active after incremental empty report", stub.instanceSkills[0].Status)
|
||||
}
|
||||
|
||||
if err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{Mode: "full", Skills: nil}); err != nil {
|
||||
t.Fatalf("full SyncAgentSkills() error = %v", err)
|
||||
}
|
||||
if stub.markMissingCalls != 1 {
|
||||
t.Fatalf("markMissingCalls = %d, want 1 for full", stub.markMissingCalls)
|
||||
}
|
||||
if stub.instanceSkills[0].Status != "missing" {
|
||||
t.Fatalf("status = %q, want missing after full empty report", stub.instanceSkills[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAgentSkillsReactivatesMissingButNotRemoved(t *testing.T) {
|
||||
contentHash := "abc123def456789012345678901234"
|
||||
versionID := 1
|
||||
removedAt := time.Now().UTC().Add(-time.Hour)
|
||||
stub := &capturingSkillRepoStub{
|
||||
skillRepoStub: skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
10: {
|
||||
ID: 10, UserID: 1, SkillKey: "weather", Name: "weather",
|
||||
SourceType: skillSourceDiscovered, Status: skillStatusActive,
|
||||
Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
11: {
|
||||
ID: 11, UserID: 1, SkillKey: "calendar", Name: "calendar",
|
||||
SourceType: skillSourceDiscovered, Status: skillStatusActive,
|
||||
Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
1: {ID: 1, ContentHash: contentHash, ObjectKey: "discovered/weather.zip", ScanStatus: "completed"},
|
||||
2: {ID: 2, ContentHash: "def456abc123789012345678901234", ObjectKey: "discovered/calendar.zip", ScanStatus: "completed"},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{
|
||||
1: {ID: 1, SkillID: 10, BlobID: 1, VersionNo: 1},
|
||||
2: {ID: 2, SkillID: 11, BlobID: 2, VersionNo: 1},
|
||||
},
|
||||
instanceSkills: []models.InstanceSkill{
|
||||
{InstanceID: 1, SkillID: 10, Status: "missing", SourceType: "discovered_in_instance", RemovedAt: &removedAt},
|
||||
{InstanceID: 1, SkillID: 11, Status: "removed", SourceType: "discovered_in_instance", RemovedAt: &removedAt},
|
||||
},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
|
||||
err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{
|
||||
Mode: "full",
|
||||
Skills: []AgentSkillRecord{
|
||||
{Identifier: "weather", ContentMD5: contentHash, Source: "discovered_in_instance"},
|
||||
{Identifier: "calendar", ContentMD5: "def456abc123789012345678901234", Source: "discovered_in_instance"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SyncAgentSkills() error = %v", err)
|
||||
}
|
||||
|
||||
var missing, removed *models.InstanceSkill
|
||||
for i := range stub.instanceSkills {
|
||||
item := &stub.instanceSkills[i]
|
||||
switch item.SkillID {
|
||||
case 10:
|
||||
missing = item
|
||||
case 11:
|
||||
removed = item
|
||||
}
|
||||
}
|
||||
if missing == nil || missing.Status != "active" || missing.RemovedAt != nil {
|
||||
t.Fatalf("missing skill revive = %#v, want active with nil RemovedAt", missing)
|
||||
}
|
||||
if removed == nil || removed.Status != "removed" {
|
||||
t.Fatalf("removed skill = %#v, want status removed", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSkillNilSafe(t *testing.T) {
|
||||
missingVersionID := 999
|
||||
versionID := 7
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {ID: 1, UserID: 1, Status: skillStatusActive, Visibility: skillVisibilityPrivate, CurrentVersionID: &missingVersionID},
|
||||
2: {ID: 2, UserID: 1, Status: skillStatusActive, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{
|
||||
7: {ID: 7, SkillID: 2, BlobID: 99},
|
||||
},
|
||||
blobs: map[int]*models.SkillBlob{},
|
||||
}
|
||||
svc := &skillService{repo: stub, storage: &importTestObjectStorage{objects: map[string][]byte{}}}
|
||||
|
||||
if _, _, err := svc.DownloadSkill(1, "user", 1); err == nil {
|
||||
t.Fatal("expected error when current version is missing")
|
||||
}
|
||||
if _, _, err := svc.DownloadSkill(1, "user", 2); err == nil {
|
||||
t.Fatal("expected error when blob is missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
skillImportConflictNone = "none"
|
||||
skillImportConflictUnchanged = "unchanged"
|
||||
skillImportConflictContentChanged = "content_changed"
|
||||
|
||||
skillImportActionAuto = "auto"
|
||||
skillImportActionNewVersion = "new_version"
|
||||
skillImportActionSaveAsNew = "save_as_new"
|
||||
skillImportActionSkip = "skip"
|
||||
|
||||
skillImportResultCreated = "created"
|
||||
skillImportResultVersioned = "versioned"
|
||||
skillImportResultUnchanged = "unchanged"
|
||||
skillImportResultSavedAsNew = "saved_as_new"
|
||||
)
|
||||
|
||||
type SkillImportPreviewItem struct {
|
||||
DirectoryName string `json:"directory_name"`
|
||||
SkillKey string `json:"skill_key"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
ConflictType string `json:"conflict_type"`
|
||||
ExistingSkillID *int `json:"existing_skill_id,omitempty"`
|
||||
ExistingName *string `json:"existing_name,omitempty"`
|
||||
CurrentVersionNo *int `json:"current_version_no,omitempty"`
|
||||
SuggestedSkillKey *string `json:"suggested_skill_key,omitempty"`
|
||||
}
|
||||
|
||||
type SkillImportDecision struct {
|
||||
DirectoryName string `json:"directory_name"`
|
||||
Action string `json:"action"`
|
||||
SkillKey *string `json:"skill_key,omitempty"`
|
||||
}
|
||||
|
||||
type SkillImportResultItem struct {
|
||||
Skill SkillPayload `json:"skill"`
|
||||
Action string `json:"action"`
|
||||
PreviousVersionNo *int `json:"previous_version_no,omitempty"`
|
||||
DirectoryName string `json:"directory_name"`
|
||||
}
|
||||
|
||||
type ImportDirectoryOptions struct {
|
||||
Action string
|
||||
OverrideSkillKey string
|
||||
}
|
||||
|
||||
func (s *skillService) PreviewHubImport(ctx context.Context, userID int, fileHeader *multipart.FileHeader) ([]SkillImportPreviewItem, error) {
|
||||
_ = ctx
|
||||
directories, _, err := readSkillArchiveDirectories(fileHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]SkillImportPreviewItem, 0, len(directories))
|
||||
for _, dir := range directories {
|
||||
item, err := s.previewImportDirectory(userID, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *skillService) ImportHubArchiveWithDecisions(ctx context.Context, userID int, fileHeader *multipart.FileHeader, decisions []SkillImportDecision) ([]SkillImportResultItem, error) {
|
||||
directories, filename, err := readSkillArchiveDirectories(fileHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decisionMap := mapSkillImportDecisions(decisions)
|
||||
hasDecisions := len(decisions) > 0
|
||||
|
||||
results := make([]SkillImportResultItem, 0, len(directories))
|
||||
for _, dir := range directories {
|
||||
preview, err := s.previewImportDirectory(userID, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := resolveImportDecision(preview, decisionMap[dir.Name], hasDecisions)
|
||||
|
||||
if preview.ConflictType == skillImportConflictUnchanged || opts.Action == skillImportActionSkip {
|
||||
if preview.ConflictType == skillImportConflictUnchanged && preview.ExistingSkillID != nil {
|
||||
payload, err := s.loadSkillPayloadByID(*preview.ExistingSkillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, SkillImportResultItem{
|
||||
Skill: *payload,
|
||||
Action: skillImportResultUnchanged,
|
||||
DirectoryName: dir.Name,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := s.importDirectoryWithOptions(ctx, userID, dir, filename, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.DirectoryName = dir.Name
|
||||
results = append(results, *result)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func readSkillArchiveDirectories(fileHeader *multipart.FileHeader) ([]extractedSkillDirectory, string, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(strings.TrimSpace(fileHeader.Filename)), ".zip") {
|
||||
return nil, "", fmt.Errorf("only .zip skill archives are supported")
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to open uploaded archive: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
raw, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read uploaded archive: %w", err)
|
||||
}
|
||||
directories, err := extractSkillDirectories(fileHeader.Filename, raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(directories) == 0 {
|
||||
return nil, "", fmt.Errorf("no skill directories found in archive")
|
||||
}
|
||||
return directories, fileHeader.Filename, nil
|
||||
}
|
||||
|
||||
func mapSkillImportDecisions(decisions []SkillImportDecision) map[string]SkillImportDecision {
|
||||
result := make(map[string]SkillImportDecision, len(decisions))
|
||||
for _, item := range decisions {
|
||||
key := strings.TrimSpace(item.DirectoryName)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveImportDecision(preview SkillImportPreviewItem, decision SkillImportDecision, hasDecision bool) ImportDirectoryOptions {
|
||||
if preview.ConflictType == skillImportConflictUnchanged {
|
||||
return ImportDirectoryOptions{Action: skillImportActionSkip}
|
||||
}
|
||||
if !hasDecision {
|
||||
return ImportDirectoryOptions{Action: skillImportActionAuto}
|
||||
}
|
||||
if strings.TrimSpace(decision.DirectoryName) == "" {
|
||||
switch preview.ConflictType {
|
||||
case skillImportConflictNone:
|
||||
return ImportDirectoryOptions{Action: skillImportActionNewVersion}
|
||||
case skillImportConflictContentChanged:
|
||||
return ImportDirectoryOptions{Action: skillImportActionNewVersion}
|
||||
default:
|
||||
return ImportDirectoryOptions{Action: skillImportActionSkip}
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(decision.Action) {
|
||||
case skillImportActionSaveAsNew:
|
||||
key := preview.SkillKey
|
||||
if preview.SuggestedSkillKey != nil && strings.TrimSpace(*preview.SuggestedSkillKey) != "" {
|
||||
key = *preview.SuggestedSkillKey
|
||||
}
|
||||
if decision.SkillKey != nil && strings.TrimSpace(*decision.SkillKey) != "" {
|
||||
key = strings.TrimSpace(*decision.SkillKey)
|
||||
}
|
||||
return ImportDirectoryOptions{Action: skillImportActionSaveAsNew, OverrideSkillKey: key}
|
||||
case skillImportActionSkip:
|
||||
return ImportDirectoryOptions{Action: skillImportActionSkip}
|
||||
default:
|
||||
return ImportDirectoryOptions{Action: skillImportActionNewVersion}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *skillService) previewImportDirectory(userID int, dir extractedSkillDirectory) (SkillImportPreviewItem, error) {
|
||||
skillKey := sanitizeSkillKey(dir.Name)
|
||||
if skillKey == "" {
|
||||
return SkillImportPreviewItem{}, fmt.Errorf("skill directory name %q is invalid", dir.Name)
|
||||
}
|
||||
contentHash := hashDirectory(dir.Files)
|
||||
item := SkillImportPreviewItem{
|
||||
DirectoryName: dir.Name,
|
||||
SkillKey: skillKey,
|
||||
ContentHash: contentHash,
|
||||
ConflictType: skillImportConflictNone,
|
||||
}
|
||||
|
||||
skill, err := s.repo.GetSkillByUserKey(userID, skillKey)
|
||||
if err != nil {
|
||||
return SkillImportPreviewItem{}, err
|
||||
}
|
||||
if skill == nil {
|
||||
return item, nil
|
||||
}
|
||||
|
||||
existingName := skill.Name
|
||||
item.ExistingSkillID = &skill.ID
|
||||
item.ExistingName = &existingName
|
||||
|
||||
existingHash, versionNo, err := s.currentSkillContentHash(skill)
|
||||
if err != nil {
|
||||
return SkillImportPreviewItem{}, err
|
||||
}
|
||||
if versionNo != nil {
|
||||
item.CurrentVersionNo = versionNo
|
||||
}
|
||||
if existingHash != "" && existingHash == contentHash {
|
||||
item.ConflictType = skillImportConflictUnchanged
|
||||
return item, nil
|
||||
}
|
||||
item.ConflictType = skillImportConflictContentChanged
|
||||
suggested := s.nextUploadSkillKey(userID, skillKey)
|
||||
item.SuggestedSkillKey = &suggested
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *skillService) currentSkillContentHash(skill *models.Skill) (string, *int, error) {
|
||||
if skill == nil || skill.CurrentVersionID == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
version, err := s.repo.GetVersionByID(*skill.CurrentVersionID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if version == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
versionNo := version.VersionNo
|
||||
blob, err := s.repo.GetBlobByID(version.BlobID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if blob == nil {
|
||||
return "", &versionNo, nil
|
||||
}
|
||||
return blob.ContentHash, &versionNo, nil
|
||||
}
|
||||
|
||||
func (s *skillService) loadSkillPayloadByID(skillID int) (*SkillPayload, error) {
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skill == nil {
|
||||
return nil, fmt.Errorf("skill not found")
|
||||
}
|
||||
payload, err := s.toSkillPayload(*skill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enrichSkillPayload(payload, *skill, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *skillService) nextUploadSkillKey(userID int, baseKey string) string {
|
||||
candidate := strings.TrimSpace(baseKey)
|
||||
if candidate == "" {
|
||||
candidate = "skill"
|
||||
}
|
||||
for i := 2; i <= 99; i++ {
|
||||
next := fmt.Sprintf("%s-%d", candidate, i)
|
||||
existing, err := s.repo.GetSkillByUserKey(userID, next)
|
||||
if err == nil && existing == nil {
|
||||
return next
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", candidate, time.Now().UTC().Unix())
|
||||
}
|
||||
|
||||
func (s *skillService) importDirectory(ctx context.Context, userID int, dir extractedSkillDirectory, originalName string) (*SkillPayload, error) {
|
||||
result, err := s.importDirectoryWithOptions(ctx, userID, dir, originalName, ImportDirectoryOptions{Action: skillImportActionAuto})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result.Skill, nil
|
||||
}
|
||||
|
||||
func (s *skillService) importDirectoryWithOptions(ctx context.Context, userID int, dir extractedSkillDirectory, originalName string, opts ImportDirectoryOptions) (*SkillImportResultItem, error) {
|
||||
baseSkillKey := sanitizeSkillKey(dir.Name)
|
||||
if baseSkillKey == "" {
|
||||
return nil, fmt.Errorf("skill directory name %q is invalid", dir.Name)
|
||||
}
|
||||
|
||||
targetSkillKey := baseSkillKey
|
||||
isSaveAsNew := opts.Action == skillImportActionSaveAsNew
|
||||
if isSaveAsNew {
|
||||
targetSkillKey = sanitizeSkillKey(opts.OverrideSkillKey)
|
||||
if targetSkillKey == "" {
|
||||
return nil, fmt.Errorf("invalid skill key for save_as_new")
|
||||
}
|
||||
}
|
||||
|
||||
contentHash := hashDirectory(dir.Files)
|
||||
archiveBytes, archiveHash, err := buildNormalizedZip(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blob, err := s.repo.GetBlobByContentHash(contentHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blob == nil {
|
||||
blob = &models.SkillBlob{
|
||||
ContentHash: contentHash, ArchiveHash: archiveHash,
|
||||
ObjectKey: fmt.Sprintf("%d/%s/%s.zip", userID, targetSkillKey, contentHash),
|
||||
FileName: fmt.Sprintf("%s.zip", targetSkillKey),
|
||||
MediaType: "application/zip", SizeBytes: int64(len(archiveBytes)),
|
||||
ScanStatus: "pending", RiskLevel: skillRiskUnknown,
|
||||
}
|
||||
if err := s.storage.PutObject(ctx, blob.ObjectKey, archiveBytes, blob.MediaType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.CreateBlob(blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.recordScan(blob, &dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := s.ensureBlobObject(ctx, blob, archiveBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blob.LastScanResultID == nil || blob.ScanStatus != "completed" {
|
||||
if err := s.recordScan(blob, &dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existingBefore, err := s.repo.GetSkillByUserKey(userID, targetSkillKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var previousVersionNo *int
|
||||
if existingBefore != nil {
|
||||
_, versionNo, err := s.currentSkillContentHash(existingBefore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previousVersionNo = versionNo
|
||||
}
|
||||
|
||||
if isSaveAsNew && existingBefore != nil {
|
||||
return nil, fmt.Errorf("skill key %q already exists", targetSkillKey)
|
||||
}
|
||||
|
||||
skill := existingBefore
|
||||
created := false
|
||||
if skill == nil {
|
||||
description := fmt.Sprintf("Imported from %s", originalName)
|
||||
skill = &models.Skill{
|
||||
UserID: userID, SkillKey: targetSkillKey, Name: dir.Name, Description: &description,
|
||||
SourceType: skillSourceUploaded, Status: "active", Visibility: skillVisibilityPrivate, RiskLevel: blob.RiskLevel,
|
||||
LastScannedAt: blob.LastScannedAt, LastScanResultID: blob.LastScanResultID,
|
||||
}
|
||||
if err := s.repo.CreateSkill(skill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created = true
|
||||
}
|
||||
|
||||
version, err := s.repo.GetVersionBySkillAndBlob(skill.ID, blob.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versionCreated := false
|
||||
if version == nil {
|
||||
latest, err := s.repo.GetLatestVersionBySkillID(skill.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versionNo := 1
|
||||
if latest != nil {
|
||||
versionNo = latest.VersionNo + 1
|
||||
}
|
||||
manifest, _ := json.Marshal(map[string]interface{}{"root_dir": dir.Name, "files": len(dir.Files)})
|
||||
manifestJSON := string(manifest)
|
||||
version = &models.SkillVersion{
|
||||
SkillID: skill.ID, BlobID: blob.ID, VersionNo: versionNo, ManifestJSON: &manifestJSON, SourceType: skillSourceUploaded,
|
||||
}
|
||||
if err := s.repo.CreateVersion(version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versionCreated = true
|
||||
}
|
||||
|
||||
skill.CurrentVersionID = &version.ID
|
||||
skill.RiskLevel = blob.RiskLevel
|
||||
skill.LastScannedAt = blob.LastScannedAt
|
||||
skill.LastScanResultID = blob.LastScanResultID
|
||||
skill.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpdateSkill(skill); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload, err := s.toSkillPayload(*skill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enrichSkillPayload(payload, *skill, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
action := skillImportResultVersioned
|
||||
if created || isSaveAsNew {
|
||||
action = skillImportResultCreated
|
||||
if isSaveAsNew {
|
||||
action = skillImportResultSavedAsNew
|
||||
}
|
||||
} else if !versionCreated {
|
||||
action = skillImportResultUnchanged
|
||||
}
|
||||
|
||||
result := &SkillImportResultItem{
|
||||
Skill: *payload,
|
||||
Action: action,
|
||||
DirectoryName: dir.Name,
|
||||
}
|
||||
if action == skillImportResultVersioned && previousVersionNo != nil {
|
||||
result.PreviousVersionNo = previousVersionNo
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
func TestPreviewImportDirectoryNone(t *testing.T) {
|
||||
svc := &skillService{repo: &skillRepoStub{skills: map[int]*models.Skill{}}}
|
||||
item, err := svc.previewImportDirectory(1, extractedSkillDirectory{
|
||||
Name: "weather",
|
||||
Files: map[string][]byte{"SKILL.md": []byte("# weather")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("previewImportDirectory() error = %v", err)
|
||||
}
|
||||
if item.ConflictType != skillImportConflictNone {
|
||||
t.Fatalf("conflict_type = %q, want %q", item.ConflictType, skillImportConflictNone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewImportDirectoryUnchanged(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
dir := extractedSkillDirectory{Name: "weather", Files: map[string][]byte{"SKILL.md": []byte("# weather")}}
|
||||
contentHash := hashDirectory(dir.Files)
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "weather", Name: "Weather", Status: skillStatusActive,
|
||||
SourceType: skillSourceUploaded, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID, VersionNo: 2}},
|
||||
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ContentHash: contentHash, ScanStatus: "completed"}},
|
||||
}
|
||||
svc := &skillService{repo: stub}
|
||||
item, err := svc.previewImportDirectory(1, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("previewImportDirectory() error = %v", err)
|
||||
}
|
||||
if item.ConflictType != skillImportConflictUnchanged {
|
||||
t.Fatalf("conflict_type = %q, want %q", item.ConflictType, skillImportConflictUnchanged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewImportDirectoryContentChanged(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "weather", Name: "Weather", Status: skillStatusActive,
|
||||
SourceType: skillSourceUploaded, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID, VersionNo: 2}},
|
||||
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ContentHash: "old-hash", ScanStatus: "completed"}},
|
||||
}
|
||||
svc := &skillService{repo: stub}
|
||||
item, err := svc.previewImportDirectory(1, extractedSkillDirectory{
|
||||
Name: "weather",
|
||||
Files: map[string][]byte{"SKILL.md": []byte("# changed")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("previewImportDirectory() error = %v", err)
|
||||
}
|
||||
if item.ConflictType != skillImportConflictContentChanged {
|
||||
t.Fatalf("conflict_type = %q, want %q", item.ConflictType, skillImportConflictContentChanged)
|
||||
}
|
||||
if item.SuggestedSkillKey == nil || *item.SuggestedSkillKey != "weather-2" {
|
||||
t.Fatalf("suggested skill key = %v, want weather-2", item.SuggestedSkillKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
func loadLiteSkillDirectoryFromWorkspace(instance *models.Instance, workspaceDir string) (extractedSkillDirectory, string, error) {
|
||||
workspaceDir = sanitizeWorkspaceRelativePath(strings.TrimSpace(workspaceDir))
|
||||
if workspaceDir == "" {
|
||||
return extractedSkillDirectory{}, "", fmt.Errorf("workspace skill directory is required")
|
||||
}
|
||||
root := runtimeSkillInstallRoot(instance)
|
||||
if root == "" {
|
||||
return extractedSkillDirectory{}, "", fmt.Errorf("runtime skill workspace root is not configured")
|
||||
}
|
||||
skillRoot, err := joinRuntimeSkillPath(root, workspaceDir)
|
||||
if err != nil {
|
||||
return extractedSkillDirectory{}, "", fmt.Errorf("workspace skill directory is invalid: %s", workspaceDir)
|
||||
}
|
||||
files, err := collectLiteSkillDirectoryFiles(skillRoot)
|
||||
if err != nil {
|
||||
return extractedSkillDirectory{}, "", err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return extractedSkillDirectory{}, "", fmt.Errorf("lite skill directory not found: %s", workspaceDir)
|
||||
}
|
||||
dir := extractedSkillDirectory{Name: workspaceDir, Files: files}
|
||||
return dir, hashDirectory(files), nil
|
||||
}
|
||||
|
||||
func resolveLiteWorkspaceDir(instanceSkill *models.InstanceSkill, skill *models.Skill) string {
|
||||
if instanceSkill != nil && instanceSkill.WorkspaceDir != nil && strings.TrimSpace(*instanceSkill.WorkspaceDir) != "" {
|
||||
return sanitizeWorkspaceRelativePath(strings.TrimSpace(*instanceSkill.WorkspaceDir))
|
||||
}
|
||||
if instanceSkill != nil {
|
||||
if key := skillKeyForRemoval(instanceSkill); key != "" && !strings.HasPrefix(key, "skill-") {
|
||||
return sanitizeWorkspaceRelativePath(key)
|
||||
}
|
||||
}
|
||||
if skill != nil && strings.TrimSpace(skill.Name) != "" {
|
||||
return sanitizeWorkspaceRelativePath(strings.TrimSpace(skill.Name))
|
||||
}
|
||||
if skill != nil {
|
||||
return sanitizeWorkspaceRelativePath(strings.TrimSpace(skill.SkillKey))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *skillService) persistDiscoveredSkillPackage(ctx context.Context, instanceID int, dir extractedSkillDirectory, contentMD5 string, existingBlob *models.SkillBlob) (*models.SkillBlob, error) {
|
||||
if s == nil || s.storage == nil {
|
||||
return nil, fmt.Errorf("object storage is not configured")
|
||||
}
|
||||
contentMD5 = strings.TrimSpace(contentMD5)
|
||||
if contentMD5 == "" {
|
||||
return nil, fmt.Errorf("content hash is required")
|
||||
}
|
||||
|
||||
archiveBytes, archiveHash, err := buildNormalizedZip(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blob := existingBlob
|
||||
if blob == nil {
|
||||
blob, err = s.repo.GetBlobByContentHash(contentMD5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if blob == nil {
|
||||
blob = &models.SkillBlob{
|
||||
ContentHash: contentMD5,
|
||||
ArchiveHash: archiveHash,
|
||||
ObjectKey: fmt.Sprintf("discovered/%d/%s/%s.zip", instanceID, sanitizeSkillKey(dir.Name), contentMD5),
|
||||
FileName: fmt.Sprintf("%s.zip", sanitizeSkillKey(dir.Name)),
|
||||
MediaType: "application/zip",
|
||||
SizeBytes: int64(len(archiveBytes)),
|
||||
ScanStatus: "pending",
|
||||
RiskLevel: skillRiskUnknown,
|
||||
}
|
||||
if err := s.storage.PutObject(ctx, blob.ObjectKey, archiveBytes, blob.MediaType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.CreateBlob(blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if strings.TrimSpace(blob.ObjectKey) == "" {
|
||||
blob.ObjectKey = fmt.Sprintf("discovered/%d/%s/%s.zip", instanceID, sanitizeSkillKey(dir.Name), contentMD5)
|
||||
blob.FileName = fmt.Sprintf("%s.zip", sanitizeSkillKey(dir.Name))
|
||||
blob.MediaType = "application/zip"
|
||||
blob.SizeBytes = int64(len(archiveBytes))
|
||||
blob.ArchiveHash = archiveHash
|
||||
if err := s.storage.PutObject(ctx, blob.ObjectKey, archiveBytes, blob.MediaType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.UpdateBlob(blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if blob.LastScanResultID == nil || !strings.EqualFold(strings.TrimSpace(blob.ScanStatus), "completed") {
|
||||
if err := s.recordScan(blob, &dir); err != nil {
|
||||
blob.ScanStatus = "failed"
|
||||
blob.UpdatedAt = timeNowUTC()
|
||||
_ = s.repo.UpdateBlob(blob)
|
||||
}
|
||||
}
|
||||
updated, err := s.repo.GetBlobByID(blob.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updated != nil {
|
||||
blob = updated
|
||||
}
|
||||
return blob, nil
|
||||
}
|
||||
|
||||
func (s *skillService) materializeSkillPackageFromWorkspace(ctx context.Context, instanceID int, workspaceDir, expectedMD5 string, targetBlobID int) (*models.SkillBlob, error) {
|
||||
instance, err := s.instanceRepo.GetByID(instanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if instance == nil {
|
||||
return nil, fmt.Errorf("instance not found")
|
||||
}
|
||||
if !isLiteRuntimeInstance(instance) && !SupportsServerWorkspaceSkillScan(instance) {
|
||||
return nil, fmt.Errorf("instance does not support workspace skill materialization")
|
||||
}
|
||||
|
||||
dir, contentMD5, err := loadLiteSkillDirectoryFromWorkspace(instance, workspaceDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var existingBlob *models.SkillBlob
|
||||
if targetBlobID > 0 {
|
||||
existingBlob, err = s.repo.GetBlobByID(targetBlobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if existingBlob == nil {
|
||||
existingBlob, err = s.repo.GetBlobByContentHash(contentMD5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if existingBlob != nil && !strings.EqualFold(strings.TrimSpace(existingBlob.ContentHash), contentMD5) {
|
||||
existingBlob.ContentHash = contentMD5
|
||||
existingBlob.ArchiveHash = contentMD5
|
||||
}
|
||||
if existingBlob != nil && strings.TrimSpace(existingBlob.ObjectKey) != "" && strings.EqualFold(strings.TrimSpace(existingBlob.ScanStatus), "completed") {
|
||||
return existingBlob, nil
|
||||
}
|
||||
return s.persistDiscoveredSkillPackage(ctx, instanceID, dir, contentMD5, existingBlob)
|
||||
}
|
||||
|
||||
func (s *skillService) reconcileLiteDiscoveredBlob(skill *models.Skill, contentHash string) (*models.SkillBlob, *models.SkillVersion, error) {
|
||||
if s == nil || s.repo == nil || skill == nil || skill.CurrentVersionID == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
contentHash = strings.TrimSpace(contentHash)
|
||||
if contentHash == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
version, err := s.repo.GetVersionByID(*skill.CurrentVersionID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if version == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
blob, err := s.repo.GetBlobByID(version.BlobID)
|
||||
if err != nil {
|
||||
return nil, version, err
|
||||
}
|
||||
if blob == nil {
|
||||
return nil, version, nil
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(blob.ContentHash), contentHash) {
|
||||
blob.ContentHash = contentHash
|
||||
blob.ArchiveHash = contentHash
|
||||
if err := s.repo.UpdateBlob(blob); err != nil {
|
||||
return nil, version, err
|
||||
}
|
||||
}
|
||||
return blob, version, nil
|
||||
}
|
||||
|
||||
func liteInventoryUsesWorkspaceHash(instance *models.Instance) bool {
|
||||
return isLiteRuntimeInstance(instance) || SupportsServerWorkspaceSkillScan(instance)
|
||||
}
|
||||
|
||||
func workspaceContentHashForRecord(instance *models.Instance, record AgentSkillRecord) string {
|
||||
if !runtimeInventoryUsesWorkspaceHash(instance) {
|
||||
return strings.TrimSpace(record.ContentMD5)
|
||||
}
|
||||
workspaceDir := sanitizeWorkspaceRelativePath(strings.TrimSpace(record.Identifier))
|
||||
if workspaceDir == "" {
|
||||
return strings.TrimSpace(record.ContentMD5)
|
||||
}
|
||||
_, computed, err := loadLiteSkillDirectoryFromWorkspace(instance, workspaceDir)
|
||||
if err != nil || strings.TrimSpace(computed) == "" {
|
||||
return strings.TrimSpace(record.ContentMD5)
|
||||
}
|
||||
return computed
|
||||
}
|
||||
|
||||
func workspaceContentHashForLiteRecord(instance *models.Instance, record AgentSkillRecord) string {
|
||||
return workspaceContentHashForRecord(instance, record)
|
||||
}
|
||||
|
||||
func runtimeInventoryUsesWorkspaceHash(instance *models.Instance) bool {
|
||||
return liteInventoryUsesWorkspaceHash(instance)
|
||||
}
|
||||
|
||||
func (s *skillService) syncSkillRecordFromBlob(skillID int, blob *models.SkillBlob) error {
|
||||
if blob == nil {
|
||||
return nil
|
||||
}
|
||||
skill, err := s.repo.GetSkillByID(skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if skill == nil {
|
||||
return nil
|
||||
}
|
||||
skill.RiskLevel = blob.RiskLevel
|
||||
skill.LastScannedAt = blob.LastScannedAt
|
||||
skill.LastScanResultID = blob.LastScanResultID
|
||||
skill.UpdatedAt = timeNowUTC()
|
||||
return s.repo.UpdateSkill(skill)
|
||||
}
|
||||
|
||||
func timeNowUTC() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
func TestMaterializeSelfHealsStaleBlobContentHash(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace := filepath.Join(root, "hermes", "user-1", "instance-1")
|
||||
skillRoot := filepath.Join(workspace, "home", ".hermes", "skills", "demo")
|
||||
if err := os.MkdirAll(skillRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "SKILL.md"), []byte("# demo\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
instance := &models.Instance{
|
||||
ID: 1,
|
||||
UserID: 1,
|
||||
Type: RuntimeTypeHermes,
|
||||
InstanceMode: InstanceModeLite,
|
||||
RuntimeType: RuntimeBackendGateway,
|
||||
WorkspacePath: strPtr(workspace),
|
||||
}
|
||||
_, contentHash, err := loadLiteSkillDirectoryFromWorkspace(instance, "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("loadLiteSkillDirectoryFromWorkspace() error = %v", err)
|
||||
}
|
||||
staleHash := "deadbeefdeadbeefdeadbeefdeadbeef"
|
||||
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive, CurrentVersionID: intPtr(10)},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{10: {ID: 10, SkillID: 1, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
blobID: {ID: blobID, ContentHash: staleHash, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: instance}}
|
||||
storage := &importTestObjectStorage{objects: map[string][]byte{}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, storage: storage, scanner: testSkillScanner{}}
|
||||
|
||||
blob, err := svc.materializeSkillPackageFromWorkspace(context.Background(), 1, "demo", staleHash, blobID)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeSkillPackageFromWorkspace() error = %v", err)
|
||||
}
|
||||
if blob == nil || strings.TrimSpace(blob.ObjectKey) == "" {
|
||||
t.Fatalf("expected materialized blob, got %#v", blob)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(stub.blobs[blobID].ContentHash), contentHash) {
|
||||
t.Fatalf("blob content_hash = %q, want %q", stub.blobs[blobID].ContentHash, contentHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceContentHashForLiteRecordOverridesAgentMD5(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace := filepath.Join(root, "hermes", "user-1", "instance-1")
|
||||
skillRoot := filepath.Join(workspace, "home", ".hermes", "skills", "yuanbao")
|
||||
if err := os.MkdirAll(skillRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "SKILL.md"), []byte("# yuanbao\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
instance := &models.Instance{
|
||||
Type: RuntimeTypeHermes, InstanceMode: InstanceModeLite,
|
||||
RuntimeType: RuntimeBackendGateway, WorkspacePath: strPtr(workspace),
|
||||
}
|
||||
got := workspaceContentHashForLiteRecord(instance, AgentSkillRecord{
|
||||
Identifier: "yuanbao", ContentMD5: "deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
})
|
||||
_, want, err := loadLiteSkillDirectoryFromWorkspace(instance, "yuanbao")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("workspaceContentHashForLiteRecord() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLiteWorkspaceDir(t *testing.T) {
|
||||
workspace := "yuanbao"
|
||||
instanceSkill := &models.InstanceSkill{
|
||||
WorkspaceDir: &workspace,
|
||||
InstallPath: strPtr("home/.hermes/skills/yuanbao"),
|
||||
}
|
||||
skill := &models.Skill{SkillKey: "yuanbao-deadbeef", Name: "yuanbao"}
|
||||
if got := resolveLiteWorkspaceDir(instanceSkill, skill); got != "yuanbao" {
|
||||
t.Fatalf("resolveLiteWorkspaceDir() = %q, want yuanbao", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLiteSkillDirectoryFromWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace := filepath.Join(root, "hermes", "user-1", "instance-1")
|
||||
skillRoot := filepath.Join(workspace, "home", ".hermes", "skills", "weather")
|
||||
if err := os.MkdirAll(filepath.Join(skillRoot, "src"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "SKILL.md"), []byte("# weather\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "src", "main.py"), []byte("print('ok')\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
instance := &models.Instance{
|
||||
Type: RuntimeTypeHermes,
|
||||
InstanceMode: InstanceModeLite,
|
||||
RuntimeType: RuntimeBackendGateway,
|
||||
WorkspacePath: strPtr(workspace),
|
||||
}
|
||||
dir, md5, err := loadLiteSkillDirectoryFromWorkspace(instance, "weather")
|
||||
if err != nil {
|
||||
t.Fatalf("loadLiteSkillDirectoryFromWorkspace() error = %v", err)
|
||||
}
|
||||
if dir.Name != "weather" || len(dir.Files) != 2 {
|
||||
t.Fatalf("unexpected dir: %#v", dir)
|
||||
}
|
||||
if md5 == "" {
|
||||
t.Fatal("expected non-empty md5")
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
type stubCommandRepo struct {
|
||||
failed *models.InstanceCommand
|
||||
}
|
||||
|
||||
func (s *stubCommandRepo) Create(*models.InstanceCommand) error { panic("not used") }
|
||||
func (s *stubCommandRepo) Update(*models.InstanceCommand) error { panic("not used") }
|
||||
func (s *stubCommandRepo) GetByID(int) (*models.InstanceCommand, error) { panic("not used") }
|
||||
func (s *stubCommandRepo) GetByInstanceIdempotencyKey(int, string) (*models.InstanceCommand, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubCommandRepo) GetNextPendingByInstance(int) (*models.InstanceCommand, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubCommandRepo) ListByInstanceID(int, int) ([]models.InstanceCommand, error) {
|
||||
panic("not used")
|
||||
}
|
||||
func (s *stubCommandRepo) FindLatestFailedCollectSkillPackage(string) (*models.InstanceCommand, error) {
|
||||
return s.failed, nil
|
||||
}
|
||||
|
||||
func TestPublishBlockedReasonCollectFailed(t *testing.T) {
|
||||
errMsg := `unexpected status 500: {"error":"skill package md5 mismatch: expected abc got def","success":false}`
|
||||
svc := &skillService{
|
||||
commandRepo: &stubCommandRepo{
|
||||
failed: &models.InstanceCommand{
|
||||
CommandType: "collect_skill_package",
|
||||
Status: "failed",
|
||||
ErrorMessage: &errMsg,
|
||||
FinishedAt: ptrTime(time.Now()),
|
||||
},
|
||||
},
|
||||
}
|
||||
skill := &models.Skill{ID: 2, Status: skillStatusActive, SourceType: skillSourceDiscovered}
|
||||
blob := &models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""}
|
||||
|
||||
reason := svc.publishBlockedReasonForSkill(skill, blob, nil, false, false)
|
||||
if reason == nil || *reason != "skill_package_collect_failed" {
|
||||
t.Fatalf("expected skill_package_collect_failed, got %v", reason)
|
||||
}
|
||||
collectErr := svc.resolvePackageCollectError(skill.ID, blob, nil, false)
|
||||
if collectErr == nil || *collectErr != errMsg {
|
||||
t.Fatalf("expected package collect error summary, got %v", collectErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBlockedReasonScanFailed(t *testing.T) {
|
||||
svc := &skillService{}
|
||||
skill := &models.Skill{ID: 1, Status: skillStatusActive, SourceType: skillSourceUploaded}
|
||||
blob := &models.SkillBlob{ScanStatus: "failed", RiskLevel: skillRiskUnknown, ObjectKey: "discovered/1/demo.zip"}
|
||||
|
||||
reason := svc.publishBlockedReasonForSkill(skill, blob, nil, false, false)
|
||||
if reason == nil || *reason != "skill_scan_failed" {
|
||||
t.Fatalf("expected skill_scan_failed, got %v", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
MaterializeJobStatusPending = "pending"
|
||||
MaterializeJobStatusRunning = "running"
|
||||
MaterializeJobStatusSucceeded = "succeeded"
|
||||
MaterializeJobStatusFailed = "failed"
|
||||
MaterializeJobStatusCancelled = "cancelled"
|
||||
MaterializeTriggerSync = "sync"
|
||||
MaterializeTriggerRetry = "retry"
|
||||
MaterializeTriggerImport = "import"
|
||||
MaterializeTriggerPublish = "publish"
|
||||
MaterializeTriggerBackfill = "backfill"
|
||||
)
|
||||
|
||||
type EnqueueMaterializeRequest struct {
|
||||
InstanceID int
|
||||
SkillID int
|
||||
BlobID int
|
||||
WorkspaceDir string
|
||||
ContentHash string
|
||||
TriggerSource string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type skillBlobReader interface {
|
||||
GetBlobByID(id int) (*models.SkillBlob, error)
|
||||
}
|
||||
|
||||
type SkillPackageMaterializeService struct {
|
||||
jobRepo repository.SkillPackageMaterializeJobRepository
|
||||
blobRepo skillBlobReader
|
||||
worker skillPackageMaterializer
|
||||
}
|
||||
|
||||
type skillPackageMaterializer interface {
|
||||
materializeSkillPackageFromWorkspace(ctx context.Context, instanceID int, workspaceDir, contentHash string, targetBlobID int) (*models.SkillBlob, error)
|
||||
syncSkillRecordFromBlob(skillID int, blob *models.SkillBlob) error
|
||||
}
|
||||
|
||||
func SkillServiceAsMaterializer(service SkillService) skillPackageMaterializer {
|
||||
if impl, ok := service.(*skillService); ok {
|
||||
return impl
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewSkillPackageMaterializeService(jobRepo repository.SkillPackageMaterializeJobRepository, blobRepo skillBlobReader, worker skillPackageMaterializer) *SkillPackageMaterializeService {
|
||||
return &SkillPackageMaterializeService{jobRepo: jobRepo, blobRepo: blobRepo, worker: worker}
|
||||
}
|
||||
|
||||
func (m *SkillPackageMaterializeService) Enqueue(ctx context.Context, req EnqueueMaterializeRequest) (*models.SkillPackageMaterializeJob, error) {
|
||||
if m == nil || m.jobRepo == nil {
|
||||
return nil, fmt.Errorf("skill package materialize service is not configured")
|
||||
}
|
||||
workspaceDir := sanitizeWorkspaceRelativePath(strings.TrimSpace(req.WorkspaceDir))
|
||||
contentHash := strings.TrimSpace(req.ContentHash)
|
||||
if req.InstanceID <= 0 || req.SkillID <= 0 || req.BlobID <= 0 || workspaceDir == "" || contentHash == "" {
|
||||
return nil, fmt.Errorf("invalid materialize enqueue request")
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
idempotencyKey = fmt.Sprintf("materialize-%d-%s", req.InstanceID, contentHash)
|
||||
}
|
||||
if existing, err := m.jobRepo.GetByIdempotencyKey(idempotencyKey); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil {
|
||||
if strings.EqualFold(strings.TrimSpace(existing.Status), MaterializeJobStatusSucceeded) {
|
||||
return existing, nil
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(existing.Status), MaterializeJobStatusFailed) {
|
||||
if err := m.jobRepo.RequeueExisting(existing.ID, req.BlobID, contentHash, workspaceDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.jobRepo.GetByID(existing.ID)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(existing.Status), MaterializeJobStatusPending) ||
|
||||
strings.EqualFold(strings.TrimSpace(existing.Status), MaterializeJobStatusRunning) {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
if m.blobRepo != nil {
|
||||
blob, err := m.blobRepo.GetBlobByID(req.BlobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blob != nil && strings.TrimSpace(blob.ObjectKey) != "" {
|
||||
if existing, err := m.jobRepo.GetByIdempotencyKey(idempotencyKey); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil {
|
||||
if !strings.EqualFold(strings.TrimSpace(existing.Status), MaterializeJobStatusSucceeded) {
|
||||
if err := m.jobRepo.MarkSucceeded(existing.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err = m.jobRepo.GetByID(existing.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
job := &models.SkillPackageMaterializeJob{
|
||||
InstanceID: req.InstanceID,
|
||||
SkillID: req.SkillID,
|
||||
BlobID: req.BlobID,
|
||||
WorkspaceDir: workspaceDir,
|
||||
ContentHash: contentHash,
|
||||
Status: MaterializeJobStatusSucceeded,
|
||||
MaxAttempts: 5,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
TriggerSource: strings.TrimSpace(req.TriggerSource),
|
||||
}
|
||||
if job.TriggerSource == "" {
|
||||
job.TriggerSource = MaterializeTriggerSync
|
||||
}
|
||||
if err := m.jobRepo.Create(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(job.Status), MaterializeJobStatusSucceeded) {
|
||||
if err := m.jobRepo.MarkSucceeded(job.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job, err = m.jobRepo.GetByID(job.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
}
|
||||
trigger := strings.TrimSpace(req.TriggerSource)
|
||||
if trigger == "" {
|
||||
trigger = MaterializeTriggerSync
|
||||
}
|
||||
|
||||
job := &models.SkillPackageMaterializeJob{
|
||||
InstanceID: req.InstanceID,
|
||||
SkillID: req.SkillID,
|
||||
BlobID: req.BlobID,
|
||||
WorkspaceDir: workspaceDir,
|
||||
ContentHash: contentHash,
|
||||
Status: MaterializeJobStatusPending,
|
||||
MaxAttempts: 5,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
TriggerSource: trigger,
|
||||
}
|
||||
if err := m.jobRepo.Create(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (m *SkillPackageMaterializeService) ReleaseToPending(id int) error {
|
||||
if m == nil || m.jobRepo == nil {
|
||||
return fmt.Errorf("skill package materialize service is not configured")
|
||||
}
|
||||
return m.jobRepo.ReleaseToPending(id)
|
||||
}
|
||||
|
||||
func (m *SkillPackageMaterializeService) ClaimNextPending(ctx context.Context, limit int) ([]models.SkillPackageMaterializeJob, error) {
|
||||
if m == nil || m.jobRepo == nil {
|
||||
return nil, fmt.Errorf("skill package materialize service is not configured")
|
||||
}
|
||||
return m.jobRepo.ClaimNextPending(ctx, limit)
|
||||
}
|
||||
|
||||
func (m *SkillPackageMaterializeService) ProcessJob(ctx context.Context, jobID int) error {
|
||||
if m == nil || m.jobRepo == nil || m.worker == nil {
|
||||
return fmt.Errorf("skill package materialize service is not configured")
|
||||
}
|
||||
job, err := m.jobRepo.GetByID(jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job == nil {
|
||||
return fmt.Errorf("materialize job not found")
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(job.Status), MaterializeJobStatusPending) {
|
||||
if err := m.jobRepo.MarkRunning(jobID); err != nil {
|
||||
return err
|
||||
}
|
||||
job, err = m.jobRepo.GetByID(jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job == nil {
|
||||
return fmt.Errorf("materialize job not found")
|
||||
}
|
||||
}
|
||||
|
||||
blob, err := m.worker.materializeSkillPackageFromWorkspace(ctx, job.InstanceID, job.WorkspaceDir, job.ContentHash, job.BlobID)
|
||||
if err != nil {
|
||||
retryable := !strings.Contains(strings.ToLower(err.Error()), "md5 mismatch")
|
||||
if markErr := m.jobRepo.MarkFailed(job.ID, err.Error(), retryable); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := m.worker.syncSkillRecordFromBlob(job.SkillID, blob); err != nil {
|
||||
if markErr := m.jobRepo.MarkFailed(job.ID, err.Error(), true); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
return m.jobRepo.MarkSucceeded(job.ID)
|
||||
}
|
||||
|
||||
func (m *SkillPackageMaterializeService) RetryJob(skillID int) error {
|
||||
if m == nil || m.jobRepo == nil {
|
||||
return fmt.Errorf("skill package materialize service is not configured")
|
||||
}
|
||||
return m.jobRepo.ResetForRetry(skillID)
|
||||
}
|
||||
|
||||
func (m *SkillPackageMaterializeService) BackfillOnce(ctx context.Context, limit int) (int, error) {
|
||||
if m == nil || m.jobRepo == nil {
|
||||
return 0, fmt.Errorf("skill package materialize service is not configured")
|
||||
}
|
||||
candidates, err := m.jobRepo.ListBackfillCandidates(limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
enqueued := 0
|
||||
for _, candidate := range candidates {
|
||||
_, err := m.Enqueue(ctx, EnqueueMaterializeRequest{
|
||||
InstanceID: candidate.InstanceID,
|
||||
SkillID: candidate.SkillID,
|
||||
BlobID: candidate.BlobID,
|
||||
WorkspaceDir: candidate.WorkspaceDir,
|
||||
ContentHash: candidate.ContentHash,
|
||||
TriggerSource: MaterializeTriggerBackfill,
|
||||
IdempotencyKey: fmt.Sprintf("materialize-%d-%s", candidate.InstanceID, candidate.ContentHash),
|
||||
})
|
||||
if err != nil {
|
||||
return enqueued, err
|
||||
}
|
||||
enqueued++
|
||||
}
|
||||
return enqueued, nil
|
||||
}
|
||||
|
||||
func (m *SkillPackageMaterializeService) FindLatestBySkillID(skillID int) (*models.SkillPackageMaterializeJob, error) {
|
||||
if m == nil || m.jobRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return m.jobRepo.FindLatestBySkillID(skillID)
|
||||
}
|
||||
|
||||
func (m *SkillPackageMaterializeService) GetObservedStatus(skillID int, blob *models.SkillBlob) (*string, *string) {
|
||||
if m == nil || m.jobRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if blob != nil && strings.TrimSpace(blob.ObjectKey) != "" {
|
||||
return nil, nil
|
||||
}
|
||||
job, err := m.jobRepo.FindLatestBySkillID(skillID)
|
||||
if err != nil || job == nil {
|
||||
return nil, nil
|
||||
}
|
||||
status := strings.TrimSpace(job.Status)
|
||||
if status == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var errSummary *string
|
||||
if job.LastError != nil && strings.TrimSpace(*job.LastError) != "" {
|
||||
summary := truncateCollectError(*job.LastError, 512)
|
||||
if summary != "" {
|
||||
errSummary = &summary
|
||||
}
|
||||
}
|
||||
return &status, errSummary
|
||||
}
|
||||
|
||||
func materializeBlockedReason(job *models.SkillPackageMaterializeJob) *string {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
reason := func(value string) *string { return &value }
|
||||
switch strings.TrimSpace(job.Status) {
|
||||
case MaterializeJobStatusRunning:
|
||||
return reason("skill_package_materializing")
|
||||
case MaterializeJobStatusPending:
|
||||
return reason("skill_package_materializing")
|
||||
case MaterializeJobStatusFailed:
|
||||
return reason("skill_package_materialize_failed")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
)
|
||||
|
||||
func TestMaterializeBlockedReason(t *testing.T) {
|
||||
running := "skill_package_materializing"
|
||||
if got := materializeBlockedReason(&models.SkillPackageMaterializeJob{Status: MaterializeJobStatusRunning}); got == nil || *got != running {
|
||||
t.Fatalf("running reason = %v, want %q", got, running)
|
||||
}
|
||||
failed := "skill_package_materialize_failed"
|
||||
if got := materializeBlockedReason(&models.SkillPackageMaterializeJob{Status: MaterializeJobStatusFailed}); got == nil || *got != failed {
|
||||
t.Fatalf("failed reason = %v, want %q", got, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBlockedReasonUsesMaterializeJob(t *testing.T) {
|
||||
svc := &skillService{
|
||||
materializeService: NewSkillPackageMaterializeService(
|
||||
&materializeJobRepoStub{latest: &models.SkillPackageMaterializeJob{Status: MaterializeJobStatusPending}},
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
}
|
||||
skill := &models.Skill{ID: 1, Status: skillStatusActive, SourceType: skillSourceDiscovered}
|
||||
blob := &models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""}
|
||||
|
||||
reason := svc.publishBlockedReasonForSkill(skill, blob, nil, false, false)
|
||||
if reason == nil || *reason != "skill_package_materializing" {
|
||||
t.Fatalf("expected skill_package_materializing, got %v", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBlockedReasonSkipsCollectWhenMaterializeJobSucceeded(t *testing.T) {
|
||||
svc := &skillService{
|
||||
materializeService: NewSkillPackageMaterializeService(
|
||||
&materializeJobRepoStub{latest: &models.SkillPackageMaterializeJob{Status: MaterializeJobStatusSucceeded}},
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
commandRepo: &stubCommandRepo{
|
||||
failed: &models.InstanceCommand{
|
||||
CommandType: "collect_skill_package",
|
||||
Status: "failed",
|
||||
ErrorMessage: strPtr("agent failed"),
|
||||
},
|
||||
},
|
||||
}
|
||||
skill := &models.Skill{ID: 1, Status: skillStatusActive, SourceType: skillSourceDiscovered}
|
||||
blob := &models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""}
|
||||
|
||||
reason := svc.publishBlockedReasonForSkill(skill, blob, nil, false, false)
|
||||
if reason == nil || *reason != "skill_package_pending" {
|
||||
t.Fatalf("expected skill_package_pending, got %v", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBlockedReasonLiteSkipsAgentCollectFailed(t *testing.T) {
|
||||
svc := &skillService{
|
||||
commandRepo: &stubCommandRepo{
|
||||
failed: &models.InstanceCommand{
|
||||
CommandType: "collect_skill_package",
|
||||
Status: "failed",
|
||||
ErrorMessage: strPtr("agent failed"),
|
||||
},
|
||||
},
|
||||
}
|
||||
skill := &models.Skill{ID: 1, Status: skillStatusActive, SourceType: skillSourceDiscovered}
|
||||
blob := &models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""}
|
||||
|
||||
reason := svc.publishBlockedReasonForSkill(skill, blob, nil, false, true)
|
||||
if reason == nil || *reason != "skill_package_pending" {
|
||||
t.Fatalf("expected skill_package_pending, got %v", reason)
|
||||
}
|
||||
collectErr := svc.resolvePackageCollectError(skill.ID, blob, nil, true)
|
||||
if collectErr != nil {
|
||||
t.Fatalf("expected nil collect error for lite, got %v", collectErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMyHubSkillsLiteAutoResolvesInstanceContext(t *testing.T) {
|
||||
versionID := 10
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "yuanbao", Name: "yuanbao", Status: skillStatusActive,
|
||||
SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
blobID: {ID: blobID, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""},
|
||||
},
|
||||
instanceSkills: []models.InstanceSkill{{
|
||||
InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance",
|
||||
}},
|
||||
tagAssignments: map[int][]int{},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{
|
||||
1: {ID: 1, UserID: 1, InstanceMode: InstanceModeLite, RuntimeType: RuntimeBackendGateway},
|
||||
}}
|
||||
svc := &skillService{
|
||||
repo: stub,
|
||||
instanceRepo: instRepo,
|
||||
commandRepo: &stubCommandRepo{
|
||||
failed: &models.InstanceCommand{
|
||||
CommandType: "collect_skill_package",
|
||||
Status: "failed",
|
||||
ErrorMessage: strPtr("agent failed"),
|
||||
},
|
||||
},
|
||||
}
|
||||
items, err := svc.ListMyHubSkills(1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMyHubSkills() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1", len(items))
|
||||
}
|
||||
if items[0].PublishBlockedReason == nil || *items[0].PublishBlockedReason != "skill_package_pending" {
|
||||
t.Fatalf("PublishBlockedReason = %v, want skill_package_pending", items[0].PublishBlockedReason)
|
||||
}
|
||||
if items[0].PackageCollectError != nil {
|
||||
t.Fatalf("PackageCollectError = %v, want nil", items[0].PackageCollectError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAgentSkillsLiteSkipsAgentEnqueue(t *testing.T) {
|
||||
stub := &capturingSkillRepoStub{
|
||||
skillRepoStub: skillRepoStub{
|
||||
skills: map[int]*models.Skill{},
|
||||
blobs: map[int]*models.SkillBlob{},
|
||||
versions: map[int]*models.SkillVersion{},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{
|
||||
1: {ID: 1, UserID: 1, InstanceMode: InstanceModeLite, RuntimeType: RuntimeBackendGateway},
|
||||
}}
|
||||
cmdSvc := &capturingInstanceCommandService{}
|
||||
matSvc := NewSkillPackageMaterializeService(&materializeJobRepoStub{}, nil, nil)
|
||||
svc := &skillService{
|
||||
repo: stub,
|
||||
instanceRepo: instRepo,
|
||||
commandService: cmdSvc,
|
||||
materializeService: matSvc,
|
||||
}
|
||||
err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{
|
||||
Skills: []AgentSkillRecord{{
|
||||
Identifier: "yuanbao",
|
||||
ContentMD5: "abc123def456789012345678901234",
|
||||
Source: "discovered_in_instance",
|
||||
InstallPath: "home/.hermes/skills/yuanbao",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SyncAgentSkills() error = %v", err)
|
||||
}
|
||||
for _, req := range cmdSvc.created {
|
||||
if req.CommandType == InstanceCommandTypeCollectSkillPackage {
|
||||
t.Fatalf("unexpected collect_skill_package command: %#v", req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueSkipsWhenObjectKeyPresent(t *testing.T) {
|
||||
objectKey := "discovered/1/demo/abc.zip"
|
||||
service := NewSkillPackageMaterializeService(
|
||||
&materializeJobRepoStub{},
|
||||
&materializeBlobRepoStub{
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
20: {ID: 20, ObjectKey: objectKey, ContentHash: "abc"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
job, err := service.Enqueue(context.Background(), EnqueueMaterializeRequest{
|
||||
InstanceID: 1,
|
||||
SkillID: 1,
|
||||
BlobID: 20,
|
||||
WorkspaceDir: "demo",
|
||||
ContentHash: "abc",
|
||||
TriggerSource: MaterializeTriggerSync,
|
||||
IdempotencyKey: "materialize-1-abc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Enqueue() error = %v", err)
|
||||
}
|
||||
if job == nil || job.Status != MaterializeJobStatusSucceeded {
|
||||
t.Fatalf("expected succeeded job, got %#v", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueRequeuesFailedMaterializeJob(t *testing.T) {
|
||||
failed := "skill package md5 mismatch"
|
||||
job := &models.SkillPackageMaterializeJob{
|
||||
ID: 7, InstanceID: 1, SkillID: 1, BlobID: 20, WorkspaceDir: "demo",
|
||||
ContentHash: "stale", Status: MaterializeJobStatusFailed, LastError: &failed,
|
||||
IdempotencyKey: "materialize-1-goodhash",
|
||||
}
|
||||
repo := &materializeJobRepoStub{
|
||||
latest: job,
|
||||
byKey: map[string]*models.SkillPackageMaterializeJob{"materialize-1-goodhash": job},
|
||||
}
|
||||
service := NewSkillPackageMaterializeService(repo, nil, nil)
|
||||
updated, err := service.Enqueue(context.Background(), EnqueueMaterializeRequest{
|
||||
InstanceID: 1,
|
||||
SkillID: 1,
|
||||
BlobID: 20,
|
||||
WorkspaceDir: "demo",
|
||||
ContentHash: "goodhash",
|
||||
IdempotencyKey: "materialize-1-goodhash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Enqueue() error = %v", err)
|
||||
}
|
||||
if updated == nil || updated.Status != MaterializeJobStatusPending {
|
||||
t.Fatalf("expected pending job, got %#v", updated)
|
||||
}
|
||||
if updated.ContentHash != "goodhash" {
|
||||
t.Fatalf("content_hash = %q, want goodhash", updated.ContentHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSkillPackageMaterializeWorkerDefaults(t *testing.T) {
|
||||
worker := NewSkillPackageMaterializeWorker(nil, 0, 0, 0, 0, true)
|
||||
if worker.perInstanceLimit != 2 {
|
||||
t.Fatalf("perInstanceLimit = %d, want 2", worker.perInstanceLimit)
|
||||
}
|
||||
if worker.concurrency != 5 {
|
||||
t.Fatalf("concurrency = %d, want 5", worker.concurrency)
|
||||
}
|
||||
}
|
||||
|
||||
type capturingInstanceCommandService struct {
|
||||
created []CreateInstanceCommandRequest
|
||||
}
|
||||
|
||||
func (c *capturingInstanceCommandService) Create(_ int, _ *int, req CreateInstanceCommandRequest) (*InstanceCommandPayload, error) {
|
||||
c.created = append(c.created, req)
|
||||
return &InstanceCommandPayload{CommandType: req.CommandType, Status: "pending"}, nil
|
||||
}
|
||||
func (c *capturingInstanceCommandService) GetNextForAgent(*AgentSession) (*AgentCommandEnvelope, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *capturingInstanceCommandService) MarkStarted(*AgentSession, int, *time.Time) error { return nil }
|
||||
func (c *capturingInstanceCommandService) MarkFinished(*AgentSession, int, AgentCommandFinishRequest) error {
|
||||
return nil
|
||||
}
|
||||
func (c *capturingInstanceCommandService) ListByInstanceID(int, int) ([]InstanceCommandPayload, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type materializeJobRepoStub struct {
|
||||
latest *models.SkillPackageMaterializeJob
|
||||
created []*models.SkillPackageMaterializeJob
|
||||
byKey map[string]*models.SkillPackageMaterializeJob
|
||||
}
|
||||
|
||||
func (s *materializeJobRepoStub) Create(job *models.SkillPackageMaterializeJob) error {
|
||||
s.created = append(s.created, job)
|
||||
if job.ID == 0 {
|
||||
job.ID = len(s.created)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) GetByID(id int) (*models.SkillPackageMaterializeJob, error) {
|
||||
for _, job := range s.created {
|
||||
if job.ID == id {
|
||||
return job, nil
|
||||
}
|
||||
}
|
||||
return s.latest, nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) GetByIdempotencyKey(key string) (*models.SkillPackageMaterializeJob, error) {
|
||||
if s.byKey != nil {
|
||||
if job, ok := s.byKey[key]; ok {
|
||||
return job, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) ClaimNextPending(context.Context, int) ([]models.SkillPackageMaterializeJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) MarkSucceeded(id int) error {
|
||||
for _, job := range s.created {
|
||||
if job.ID == id {
|
||||
job.Status = MaterializeJobStatusSucceeded
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) MarkFailed(id int, msg string, _ bool) error {
|
||||
for _, job := range s.created {
|
||||
if job.ID == id {
|
||||
job.Status = MaterializeJobStatusFailed
|
||||
job.LastError = &msg
|
||||
}
|
||||
}
|
||||
if s.latest != nil && s.latest.ID == id {
|
||||
s.latest.Status = MaterializeJobStatusFailed
|
||||
s.latest.LastError = &msg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) MarkRunning(id int) error {
|
||||
for _, job := range s.created {
|
||||
if job.ID == id {
|
||||
job.Status = MaterializeJobStatusRunning
|
||||
}
|
||||
}
|
||||
if s.latest != nil && s.latest.ID == id {
|
||||
s.latest.Status = MaterializeJobStatusRunning
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) ReleaseToPending(int) error { return nil }
|
||||
func (s *materializeJobRepoStub) ResetForRetry(int) error { return nil }
|
||||
func (s *materializeJobRepoStub) RequeueExisting(id, blobID int, contentHash, workspaceDir string) error {
|
||||
for _, job := range s.created {
|
||||
if job.ID == id {
|
||||
job.Status = MaterializeJobStatusPending
|
||||
job.BlobID = blobID
|
||||
job.ContentHash = contentHash
|
||||
job.WorkspaceDir = workspaceDir
|
||||
job.LastError = nil
|
||||
}
|
||||
}
|
||||
if s.latest != nil && s.latest.ID == id {
|
||||
s.latest.Status = MaterializeJobStatusPending
|
||||
s.latest.BlobID = blobID
|
||||
s.latest.ContentHash = contentHash
|
||||
s.latest.WorkspaceDir = workspaceDir
|
||||
s.latest.LastError = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) FindLatestBySkillID(int) (*models.SkillPackageMaterializeJob, error) {
|
||||
return s.latest, nil
|
||||
}
|
||||
func (s *materializeJobRepoStub) CountPendingByInstance(int) (int, error) { return 0, nil }
|
||||
func (s *materializeJobRepoStub) ListBackfillCandidates(int) ([]repository.SkillPackageMaterializeBackfillCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type materializeBlobRepoStub struct {
|
||||
blobs map[int]*models.SkillBlob
|
||||
}
|
||||
|
||||
func (s *materializeBlobRepoStub) GetBlobByID(id int) (*models.SkillBlob, error) {
|
||||
if s.blobs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.blobs[id], nil
|
||||
}
|
||||
|
||||
type testSkillScanner struct{}
|
||||
|
||||
func (testSkillScanner) ScanArchive(context.Context, string, []byte, map[string]string) (string, map[string]interface{}, string, error) {
|
||||
return skillRiskNone, map[string]interface{}{}, "ok", nil
|
||||
}
|
||||
func (testSkillScanner) AvailableAnalyzers(context.Context) ([]string, error) { return nil, nil }
|
||||
|
||||
func TestProcessJobMaterializesFromWorkspaceWithStorage(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace := filepath.Join(root, "hermes", "user-1", "instance-1")
|
||||
skillRoot := filepath.Join(workspace, "home", ".hermes", "skills", "demo")
|
||||
if err := os.MkdirAll(filepath.Join(skillRoot, "src"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "SKILL.md"), []byte("# demo\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
instance := &models.Instance{
|
||||
ID: 1,
|
||||
UserID: 1,
|
||||
Type: RuntimeTypeHermes,
|
||||
InstanceMode: InstanceModeLite,
|
||||
RuntimeType: RuntimeBackendGateway,
|
||||
WorkspacePath: strPtr(workspace),
|
||||
}
|
||||
_, contentHash, err := loadLiteSkillDirectoryFromWorkspace(instance, "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("loadLiteSkillDirectoryFromWorkspace() error = %v", err)
|
||||
}
|
||||
|
||||
blobID := 20
|
||||
stub := &skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
1: {
|
||||
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
||||
SourceType: skillSourceDiscovered, CurrentVersionID: intPtr(10),
|
||||
},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{10: {ID: 10, SkillID: 1, BlobID: blobID}},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
blobID: {ID: blobID, ContentHash: contentHash, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: instance}}
|
||||
storage := &importTestObjectStorage{objects: map[string][]byte{}}
|
||||
svc := &skillService{
|
||||
repo: stub,
|
||||
instanceRepo: instRepo,
|
||||
storage: storage,
|
||||
scanner: testSkillScanner{},
|
||||
}
|
||||
job := &models.SkillPackageMaterializeJob{
|
||||
ID: 1, InstanceID: 1, SkillID: 1, BlobID: blobID,
|
||||
WorkspaceDir: "demo", ContentHash: contentHash, Status: MaterializeJobStatusPending,
|
||||
}
|
||||
jobRepo := &materializeJobRepoStub{latest: job, created: []*models.SkillPackageMaterializeJob{job}}
|
||||
matSvc := NewSkillPackageMaterializeService(jobRepo, stub, SkillServiceAsMaterializer(svc))
|
||||
|
||||
if err := matSvc.ProcessJob(context.Background(), job.ID); err != nil {
|
||||
t.Fatalf("ProcessJob() error = %v", err)
|
||||
}
|
||||
if job.Status != MaterializeJobStatusSucceeded {
|
||||
t.Fatalf("job status = %q, want %q", job.Status, MaterializeJobStatusSucceeded)
|
||||
}
|
||||
updated, err := stub.GetBlobByID(blobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated == nil || strings.TrimSpace(updated.ObjectKey) == "" {
|
||||
t.Fatalf("expected blob object key, got %#v", updated)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(updated.ScanStatus), "completed") {
|
||||
t.Fatalf("blob scan_status = %q, want completed", updated.ScanStatus)
|
||||
}
|
||||
if _, ok := storage.objects[updated.ObjectKey]; !ok {
|
||||
t.Fatalf("storage missing object %q", updated.ObjectKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SkillPackageMaterializeWorker struct {
|
||||
service *SkillPackageMaterializeService
|
||||
tick time.Duration
|
||||
batchSize int
|
||||
concurrency int
|
||||
perInstanceLimit int
|
||||
enabled bool
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
func NewSkillPackageMaterializeWorker(service *SkillPackageMaterializeService, tick time.Duration, batchSize, concurrency, perInstanceLimit int, enabled bool) *SkillPackageMaterializeWorker {
|
||||
if tick <= 0 {
|
||||
tick = 2 * time.Second
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 5
|
||||
}
|
||||
if concurrency <= 0 {
|
||||
concurrency = 5
|
||||
}
|
||||
if perInstanceLimit <= 0 {
|
||||
perInstanceLimit = 2
|
||||
}
|
||||
return &SkillPackageMaterializeWorker{
|
||||
service: service,
|
||||
tick: tick,
|
||||
batchSize: batchSize,
|
||||
concurrency: concurrency,
|
||||
perInstanceLimit: perInstanceLimit,
|
||||
enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SkillPackageMaterializeWorker) Start() {
|
||||
if w == nil || !w.enabled || w.service == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.running {
|
||||
return
|
||||
}
|
||||
w.stopChan = make(chan struct{})
|
||||
w.running = true
|
||||
go w.loop(w.stopChan)
|
||||
}
|
||||
|
||||
func (w *SkillPackageMaterializeWorker) Stop() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.running {
|
||||
return
|
||||
}
|
||||
close(w.stopChan)
|
||||
w.running = false
|
||||
}
|
||||
|
||||
func (w *SkillPackageMaterializeWorker) loop(stop <-chan struct{}) {
|
||||
ctx := context.Background()
|
||||
if count, err := w.service.BackfillOnce(ctx, 500); err != nil {
|
||||
log.Printf("skill package materialize backfill failed: %v", err)
|
||||
} else if count > 0 {
|
||||
log.Printf("skill package materialize backfill enqueued %d jobs", count)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(w.tick)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.processBatch(context.Background())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SkillPackageMaterializeWorker) processBatch(ctx context.Context) {
|
||||
jobs, err := w.service.ClaimNextPending(ctx, w.batchSize)
|
||||
if err != nil {
|
||||
log.Printf("skill package materialize claim failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, w.concurrency)
|
||||
instanceActive := make(map[int]int)
|
||||
var instanceMu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, job := range jobs {
|
||||
instanceMu.Lock()
|
||||
if instanceActive[job.InstanceID] >= w.perInstanceLimit {
|
||||
instanceMu.Unlock()
|
||||
if err := w.service.ReleaseToPending(job.ID); err != nil {
|
||||
log.Printf("skill package materialize release job %d failed: %v", job.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
instanceActive[job.InstanceID]++
|
||||
instanceMu.Unlock()
|
||||
|
||||
job := job
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer func() {
|
||||
instanceMu.Lock()
|
||||
instanceActive[job.InstanceID]--
|
||||
if instanceActive[job.InstanceID] <= 0 {
|
||||
delete(instanceActive, job.InstanceID)
|
||||
}
|
||||
instanceMu.Unlock()
|
||||
}()
|
||||
if err := w.service.ProcessJob(ctx, job.ID); err != nil {
|
||||
log.Printf("skill package materialize job %d failed: %v", job.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
)
|
||||
|
||||
func TestSkillPackageMaterializeWorkerReleasesWhenInstanceLimitReached(t *testing.T) {
|
||||
repo := &workerMaterializeJobRepoStub{
|
||||
jobs: []models.SkillPackageMaterializeJob{
|
||||
{ID: 1, InstanceID: 9, Status: MaterializeJobStatusRunning},
|
||||
{ID: 2, InstanceID: 9, Status: MaterializeJobStatusRunning},
|
||||
{ID: 3, InstanceID: 9, Status: MaterializeJobStatusRunning},
|
||||
},
|
||||
}
|
||||
service := NewSkillPackageMaterializeService(repo, nil, stubMaterializer{})
|
||||
worker := NewSkillPackageMaterializeWorker(service, time.Second, 3, 5, 2, true)
|
||||
|
||||
worker.processBatch(context.Background())
|
||||
|
||||
if repo.released != 1 {
|
||||
t.Fatalf("released = %d, want 1", repo.released)
|
||||
}
|
||||
}
|
||||
|
||||
type stubMaterializer struct{}
|
||||
|
||||
func (stubMaterializer) materializeSkillPackageFromWorkspace(context.Context, int, string, string, int) (*models.SkillBlob, error) {
|
||||
return &models.SkillBlob{ObjectKey: "discovered/1/demo/hash.zip", ScanStatus: "completed"}, nil
|
||||
}
|
||||
|
||||
func (stubMaterializer) syncSkillRecordFromBlob(int, *models.SkillBlob) error { return nil }
|
||||
|
||||
type workerMaterializeJobRepoStub struct {
|
||||
jobs []models.SkillPackageMaterializeJob
|
||||
released int
|
||||
}
|
||||
|
||||
func (s *workerMaterializeJobRepoStub) Create(*models.SkillPackageMaterializeJob) error { return nil }
|
||||
func (s *workerMaterializeJobRepoStub) GetByID(id int) (*models.SkillPackageMaterializeJob, error) {
|
||||
for i := range s.jobs {
|
||||
if s.jobs[i].ID == id {
|
||||
job := s.jobs[i]
|
||||
return &job, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *workerMaterializeJobRepoStub) GetByIdempotencyKey(string) (*models.SkillPackageMaterializeJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *workerMaterializeJobRepoStub) ClaimNextPending(context.Context, int) ([]models.SkillPackageMaterializeJob, error) {
|
||||
return s.jobs, nil
|
||||
}
|
||||
func (s *workerMaterializeJobRepoStub) MarkSucceeded(int) error { return nil }
|
||||
func (s *workerMaterializeJobRepoStub) MarkFailed(int, string, bool) error { return nil }
|
||||
func (s *workerMaterializeJobRepoStub) MarkRunning(int) error { return nil }
|
||||
func (s *workerMaterializeJobRepoStub) ReleaseToPending(int) error {
|
||||
s.released++
|
||||
return nil
|
||||
}
|
||||
func (s *workerMaterializeJobRepoStub) ResetForRetry(int) error { return nil }
|
||||
func (s *workerMaterializeJobRepoStub) RequeueExisting(int, int, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *workerMaterializeJobRepoStub) FindLatestBySkillID(int) (*models.SkillPackageMaterializeJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *workerMaterializeJobRepoStub) CountPendingByInstance(int) (int, error) { return 0, nil }
|
||||
func (s *workerMaterializeJobRepoStub) ListBackfillCandidates(int) ([]repository.SkillPackageMaterializeBackfillCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
const runtimeSkillDiscoveryMaxDepth = 2
|
||||
|
||||
type runtimeSkillDiscovery struct {
|
||||
RelativePath string
|
||||
SkillRoot string
|
||||
}
|
||||
|
||||
func runtimeSkillInstallRoot(instance *models.Instance) string {
|
||||
if instance == nil || instance.WorkspacePath == nil || strings.TrimSpace(*instance.WorkspacePath) == "" {
|
||||
return ""
|
||||
}
|
||||
workspacePath := filepath.Clean(strings.TrimSpace(*instance.WorkspacePath))
|
||||
if isLiteRuntimeInstance(instance) {
|
||||
if strings.EqualFold(strings.TrimSpace(instance.Type), RuntimeTypeHermes) {
|
||||
return filepath.Join(workspacePath, "home", ".hermes", "skills")
|
||||
}
|
||||
return filepath.Join(workspacePath, "home", ".openclaw", "workspace", "skills")
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(instance.Type), RuntimeTypeHermes) {
|
||||
return filepath.Join(workspacePath, ".hermes", "skills")
|
||||
}
|
||||
return filepath.Join(workspacePath, "home", ".openclaw", "workspace", "skills")
|
||||
}
|
||||
|
||||
func liteSkillInstallRoot(instance *models.Instance) string {
|
||||
return runtimeSkillInstallRoot(instance)
|
||||
}
|
||||
|
||||
func sanitizeWorkspaceRelativePath(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.ReplaceAll(value, "\\", "/")
|
||||
value = strings.Trim(value, "/")
|
||||
if value == "" || strings.Contains(value, "..") {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, strings.Count(value, "/")+1)
|
||||
for _, part := range strings.Split(value, "/") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "." || part == ".." || strings.HasPrefix(part, ".") {
|
||||
return ""
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
if len(parts) == 0 || len(parts) > runtimeSkillDiscoveryMaxDepth {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func skillKeyFromRelativePath(relativePath string) string {
|
||||
relativePath = sanitizeWorkspaceRelativePath(relativePath)
|
||||
if relativePath == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(relativePath, "/")
|
||||
return sanitizeSkillKey(parts[len(parts)-1])
|
||||
}
|
||||
|
||||
func runtimeSkillInstallRelativePath(instance *models.Instance, relativePath string) string {
|
||||
relativePath = sanitizeWorkspaceRelativePath(relativePath)
|
||||
if relativePath == "" {
|
||||
return ""
|
||||
}
|
||||
if instance == nil || instance.WorkspacePath == nil {
|
||||
return relativePath
|
||||
}
|
||||
workspacePath := filepath.Clean(strings.TrimSpace(*instance.WorkspacePath))
|
||||
target := filepath.Join(runtimeSkillInstallRoot(instance), filepath.FromSlash(relativePath))
|
||||
rel, err := filepath.Rel(workspacePath, target)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(relativePath)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func joinRuntimeSkillPath(root, relativePath string) (string, error) {
|
||||
root = filepath.Clean(strings.TrimSpace(root))
|
||||
relativePath = sanitizeWorkspaceRelativePath(relativePath)
|
||||
if root == "" || relativePath == "" {
|
||||
return "", filepath.ErrBadPattern
|
||||
}
|
||||
target := filepath.Join(root, filepath.FromSlash(relativePath))
|
||||
if !isPathWithin(root, target) {
|
||||
return "", filepath.ErrBadPattern
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func discoverRuntimeSkillDirectories(root string, maxDepth int) ([]runtimeSkillDiscovery, error) {
|
||||
root = filepath.Clean(strings.TrimSpace(root))
|
||||
if root == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = runtimeSkillDiscoveryMaxDepth
|
||||
}
|
||||
result := make([]runtimeSkillDiscovery, 0)
|
||||
var walk func(currentRoot, relativePrefix string, depth int) error
|
||||
walk = func(currentRoot, relativePrefix string, depth int) error {
|
||||
entries, err := readDirNames(currentRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := strings.TrimSpace(entry.Name)
|
||||
if name == "" || strings.HasPrefix(name, ".") || name == ".tmp" {
|
||||
continue
|
||||
}
|
||||
if !entry.IsDir {
|
||||
continue
|
||||
}
|
||||
skillRoot := filepath.Join(currentRoot, name)
|
||||
relativePath := name
|
||||
if relativePrefix != "" {
|
||||
relativePath = relativePrefix + "/" + name
|
||||
}
|
||||
relativePath = sanitizeWorkspaceRelativePath(relativePath)
|
||||
if relativePath == "" {
|
||||
continue
|
||||
}
|
||||
files, err := collectLiteSkillDirectoryFiles(skillRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) > 0 {
|
||||
result = append(result, runtimeSkillDiscovery{
|
||||
RelativePath: relativePath,
|
||||
SkillRoot: skillRoot,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if depth < maxDepth {
|
||||
if err := walk(skillRoot, relativePath, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(root, "", 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readDirNames(path string) ([]dirEntryName, error) {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]dirEntryName, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
result = append(result, dirEntryName{Name: entry.Name(), IsDir: entry.IsDir()})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type dirEntryName struct {
|
||||
Name string
|
||||
IsDir bool
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"clawreef/internal/models"
|
||||
"clawreef/internal/repository"
|
||||
)
|
||||
|
||||
type runtimeSkillSyncDeps struct {
|
||||
bindingRepo repository.InstanceRuntimeBindingRepository
|
||||
runtimePodRepo repository.RuntimePodRepository
|
||||
runtimeAgentClient RuntimeAgentClient
|
||||
}
|
||||
|
||||
func (s *skillService) ConfigureRuntimeSkillSync(bindingRepo repository.InstanceRuntimeBindingRepository, runtimePodRepo repository.RuntimePodRepository, agentClient RuntimeAgentClient) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.runtimeSkillSync = &runtimeSkillSyncDeps{
|
||||
bindingRepo: bindingRepo,
|
||||
runtimePodRepo: runtimePodRepo,
|
||||
runtimeAgentClient: agentClient,
|
||||
}
|
||||
}
|
||||
|
||||
func ConfigureSkillRuntimeSync(service SkillService, bindingRepo repository.InstanceRuntimeBindingRepository, runtimePodRepo repository.RuntimePodRepository, agentClient RuntimeAgentClient) {
|
||||
if impl, ok := service.(*skillService); ok {
|
||||
impl.ConfigureRuntimeSkillSync(bindingRepo, runtimePodRepo, agentClient)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *skillService) SyncRuntimeAgentSkillsReport(payload map[string]any) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("skill service is not configured")
|
||||
}
|
||||
reports, mode, reportedAt, err := parseRuntimeAgentSkillsReport(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, report := range reports {
|
||||
if report.InstanceID <= 0 {
|
||||
continue
|
||||
}
|
||||
skills := make([]AgentSkillRecord, 0, len(report.Skills))
|
||||
for _, record := range report.Skills {
|
||||
record.Source = normalizeRuntimeSkillSource(record.Source)
|
||||
skills = append(skills, record)
|
||||
}
|
||||
req := AgentSkillInventoryReportRequest{
|
||||
AgentID: fmt.Sprintf("runtime-instance-%d", report.InstanceID),
|
||||
ReportedAt: reportedAt,
|
||||
Mode: mode,
|
||||
Trigger: "runtime_agent_report",
|
||||
Skills: skills,
|
||||
}
|
||||
if err := s.SyncAgentSkills(report.InstanceID, req); err != nil {
|
||||
return err
|
||||
}
|
||||
s.completePendingSkillInventorySync(report.InstanceID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillService) RequestLiteSkillInventorySync(instanceID int) error {
|
||||
if s == nil || s.instanceRepo == nil {
|
||||
return fmt.Errorf("skill service is not configured")
|
||||
}
|
||||
instance, err := s.instanceRepo.GetByID(instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if instance == nil {
|
||||
return fmt.Errorf("instance not found")
|
||||
}
|
||||
if err := EnsureInstanceWorkspacePathForServerScan(context.Background(), s.instanceRepo, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isLiteRuntimeInstance(instance) && !SupportsServerWorkspaceSkillScan(instance) {
|
||||
return fmt.Errorf("instance does not support workspace skill inventory sync")
|
||||
}
|
||||
|
||||
if SupportsServerWorkspaceSkillScan(instance) {
|
||||
workspaceMode := "full"
|
||||
willResyncAgent := isLiteRuntimeInstance(instance) && s.runtimeSkillSync != nil
|
||||
if willResyncAgent {
|
||||
workspaceMode = "incremental"
|
||||
}
|
||||
if err := s.syncRuntimeSkillsFromWorkspace(instanceID, workspaceMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if !willResyncAgent {
|
||||
s.completePendingSkillInventorySync(instanceID)
|
||||
}
|
||||
}
|
||||
|
||||
if !isLiteRuntimeInstance(instance) || s.runtimeSkillSync == nil {
|
||||
return nil
|
||||
}
|
||||
deps := s.runtimeSkillSync
|
||||
if deps.bindingRepo == nil || deps.runtimePodRepo == nil || deps.runtimeAgentClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
binding, err := deps.bindingRepo.GetRunningByInstanceID(ctx, instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve runtime binding: %w", err)
|
||||
}
|
||||
if binding == nil {
|
||||
binding, err = deps.bindingRepo.GetByInstanceID(ctx, instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve runtime binding: %w", err)
|
||||
}
|
||||
}
|
||||
if binding == nil || binding.Generation != instance.RuntimeGeneration {
|
||||
return nil
|
||||
}
|
||||
runtimePod, err := deps.runtimePodRepo.GetByID(ctx, binding.RuntimePodID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve runtime pod: %w", err)
|
||||
}
|
||||
if runtimePod != nil && runtimePod.AgentEndpoint != nil && strings.TrimSpace(*runtimePod.AgentEndpoint) != "" {
|
||||
if err := deps.runtimeAgentClient.ResyncInstanceSkills(ctx, strings.TrimSpace(*runtimePod.AgentEndpoint), instanceID, "full"); err != nil {
|
||||
return fmt.Errorf("failed to request runtime skill inventory resync: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillService) syncRuntimeSkillsFromWorkspace(instanceID int, mode string) error {
|
||||
if s == nil || s.instanceRepo == nil {
|
||||
return fmt.Errorf("skill service is not configured")
|
||||
}
|
||||
instance, err := s.instanceRepo.GetByID(instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if instance == nil {
|
||||
return fmt.Errorf("instance not found")
|
||||
}
|
||||
if err := EnsureInstanceWorkspacePathForServerScan(context.Background(), s.instanceRepo, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isLiteRuntimeInstance(instance) && !SupportsServerWorkspaceSkillScan(instance) {
|
||||
return fmt.Errorf("instance does not support workspace skill inventory sync")
|
||||
}
|
||||
|
||||
root := runtimeSkillInstallRoot(instance)
|
||||
if root == "" {
|
||||
return fmt.Errorf("runtime skill workspace root is not configured")
|
||||
}
|
||||
|
||||
records := make([]AgentSkillRecord, 0)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return s.SyncAgentSkills(instanceID, runtimeWorkspaceSkillInventoryRequest(instanceID, mode, records))
|
||||
}
|
||||
return fmt.Errorf("failed to inspect runtime skill directory: %w", err)
|
||||
}
|
||||
discoveries, err := discoverRuntimeSkillDirectories(root, runtimeSkillDiscoveryMaxDepth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan runtime skill directory: %w", err)
|
||||
}
|
||||
|
||||
for _, discovery := range discoveries {
|
||||
files, err := collectLiteSkillDirectoryFiles(discovery.SkillRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan runtime skill %q: %w", discovery.RelativePath, err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
continue
|
||||
}
|
||||
records = append(records, AgentSkillRecord{
|
||||
Identifier: discovery.RelativePath,
|
||||
InstallPath: runtimeSkillInstallRelativePath(instance, discovery.RelativePath),
|
||||
ContentMD5: hashDirectory(files),
|
||||
Source: "discovered_in_instance",
|
||||
Type: "agent-skill",
|
||||
})
|
||||
}
|
||||
|
||||
return s.SyncAgentSkills(instanceID, runtimeWorkspaceSkillInventoryRequest(instanceID, mode, records))
|
||||
}
|
||||
|
||||
func (s *skillService) syncLiteSkillsFromWorkspace(instanceID int) error {
|
||||
return s.syncRuntimeSkillsFromWorkspace(instanceID, "full")
|
||||
}
|
||||
|
||||
func runtimeWorkspaceSkillInventoryRequest(instanceID int, mode string, records []AgentSkillRecord) AgentSkillInventoryReportRequest {
|
||||
now := time.Now().UTC()
|
||||
normalizedMode := strings.TrimSpace(mode)
|
||||
if normalizedMode == "" {
|
||||
normalizedMode = "full"
|
||||
}
|
||||
return AgentSkillInventoryReportRequest{
|
||||
AgentID: fmt.Sprintf("workspace-scan-instance-%d", instanceID),
|
||||
ReportedAt: &now,
|
||||
Mode: normalizedMode,
|
||||
Trigger: "runtime_workspace_scan",
|
||||
Skills: records,
|
||||
}
|
||||
}
|
||||
|
||||
func liteWorkspaceSkillInventoryRequest(instanceID int, records []AgentSkillRecord) AgentSkillInventoryReportRequest {
|
||||
return runtimeWorkspaceSkillInventoryRequest(instanceID, "full", records)
|
||||
}
|
||||
|
||||
func liteSkillInstallRelativePath(instance *models.Instance, skillName string) string {
|
||||
return runtimeSkillInstallRelativePath(instance, skillName)
|
||||
}
|
||||
|
||||
func collectLiteSkillDirectoryFiles(skillRoot string) (map[string][]byte, error) {
|
||||
manifestPath := filepath.Join(skillRoot, "SKILL.md")
|
||||
info, err := os.Stat(manifestPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
files := map[string][]byte{}
|
||||
err = filepath.WalkDir(skillRoot, func(current string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(skillRoot, current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = normalizeSkillRelPath(filepath.ToSlash(rel))
|
||||
if rel == "" || hasHiddenPathSegment(rel) {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[rel] = body
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
type runtimeAgentInstanceSkillReport struct {
|
||||
InstanceID int
|
||||
Skills []AgentSkillRecord
|
||||
}
|
||||
|
||||
func parseRuntimeAgentSkillsReport(payload map[string]any) ([]runtimeAgentInstanceSkillReport, string, *time.Time, error) {
|
||||
if payload == nil {
|
||||
return nil, "", nil, fmt.Errorf("skills report payload is required")
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("failed to encode skills report payload: %w", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Mode string `json:"mode"`
|
||||
ReportedAt *time.Time `json:"reported_at"`
|
||||
Instances []struct {
|
||||
InstanceID int `json:"instance_id"`
|
||||
Skills []AgentSkillRecord `json:"skills"`
|
||||
} `json:"instances"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
return nil, "", nil, fmt.Errorf("failed to decode skills report payload: %w", err)
|
||||
}
|
||||
mode := strings.TrimSpace(decoded.Mode)
|
||||
if mode == "" {
|
||||
mode = "full"
|
||||
}
|
||||
reports := make([]runtimeAgentInstanceSkillReport, 0, len(decoded.Instances))
|
||||
for _, item := range decoded.Instances {
|
||||
reports = append(reports, runtimeAgentInstanceSkillReport{
|
||||
InstanceID: item.InstanceID,
|
||||
Skills: item.Skills,
|
||||
})
|
||||
}
|
||||
return reports, mode, decoded.ReportedAt, nil
|
||||
}
|
||||
|
||||
func normalizeRuntimeSkillSource(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "runtime", "discovered", "agent-skill", "agent_skill":
|
||||
return "discovered_in_instance"
|
||||
default:
|
||||
return normalizeSkillSource(value)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *skillService) CompletePendingSkillInventorySync(instanceID int) {
|
||||
s.completePendingSkillInventorySync(instanceID)
|
||||
}
|
||||
|
||||
func (s *skillService) completePendingSkillInventorySync(instanceID int) {
|
||||
if s == nil || s.commandRepo == nil || instanceID <= 0 {
|
||||
return
|
||||
}
|
||||
commands, err := s.commandRepo.ListByInstanceID(instanceID, 20)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, command := range commands {
|
||||
if command.CommandType != InstanceCommandTypeSyncSkillInventory {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(command.Status) {
|
||||
case instanceCommandStatusPending, instanceCommandStatusDispatched, instanceCommandStatusRunning:
|
||||
command.Status = instanceCommandStatusSucceeded
|
||||
command.FinishedAt = &now
|
||||
command.UpdatedAt = now
|
||||
_ = s.commandRepo.Update(&command)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SupportsServerWorkspaceSkillScan(instance *models.Instance) bool {
|
||||
if instance == nil || instance.WorkspacePath == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(*instance.WorkspacePath) == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(instance.Type)) {
|
||||
case "hermes", "openclaw":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsLiteRuntimeInstance(instance *models.Instance) bool {
|
||||
return isLiteRuntimeInstance(instance)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clawreef/internal/models"
|
||||
)
|
||||
|
||||
func TestParseRuntimeAgentSkillsReport(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"mode": "full",
|
||||
"instances": []map[string]any{
|
||||
{
|
||||
"instance_id": 12,
|
||||
"skills": []map[string]any{
|
||||
{
|
||||
"identifier": "weather",
|
||||
"content_md5": "abc123",
|
||||
"source": "runtime",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reports, mode, _, err := parseRuntimeAgentSkillsReport(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("parseRuntimeAgentSkillsReport() error = %v", err)
|
||||
}
|
||||
if mode != "full" {
|
||||
t.Fatalf("mode = %q, want full", mode)
|
||||
}
|
||||
if len(reports) != 1 || reports[0].InstanceID != 12 {
|
||||
t.Fatalf("unexpected reports: %#v", reports)
|
||||
}
|
||||
if len(reports[0].Skills) != 1 || reports[0].Skills[0].Identifier != "weather" {
|
||||
t.Fatalf("unexpected skills: %#v", reports[0].Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRuntimeSkillSource(t *testing.T) {
|
||||
if got := normalizeRuntimeSkillSource("runtime"); got != "discovered_in_instance" {
|
||||
t.Fatalf("normalizeRuntimeSkillSource(runtime) = %q", got)
|
||||
}
|
||||
if got := normalizeRuntimeSkillSource("injected_by_clawmanager"); got != "injected_by_clawmanager" {
|
||||
t.Fatalf("normalizeRuntimeSkillSource(injected) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectLiteSkillDirectoryFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skillRoot := filepath.Join(root, "weather")
|
||||
if err := os.MkdirAll(filepath.Join(skillRoot, "src"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "SKILL.md"), []byte("# weather\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "src", "main.py"), []byte("print('ok')\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := collectLiteSkillDirectoryFiles(skillRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("collectLiteSkillDirectoryFiles() error = %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("files = %#v, want 2 entries", files)
|
||||
}
|
||||
if got := hashDirectory(files); got == "" {
|
||||
t.Fatal("expected non-empty content md5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectLiteSkillDirectoryFilesSkipsWithoutManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skillRoot := filepath.Join(root, "orphan")
|
||||
if err := os.MkdirAll(skillRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := collectLiteSkillDirectoryFiles(skillRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("collectLiteSkillDirectoryFiles() error = %v", err)
|
||||
}
|
||||
if files != nil {
|
||||
t.Fatalf("files = %#v, want nil", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRuntimeSkillDirectoriesNestedCategory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
categoryRoot := filepath.Join(root, "productivity")
|
||||
skillRoot := filepath.Join(categoryRoot, "my-skill")
|
||||
for _, dir := range []string{skillRoot, filepath.Join(skillRoot, "src")} {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "SKILL.md"), []byte("# my skill\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillRoot, "src", "main.py"), []byte("print('ok')\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
discoveries, err := discoverRuntimeSkillDirectories(root, runtimeSkillDiscoveryMaxDepth)
|
||||
if err != nil {
|
||||
t.Fatalf("discoverRuntimeSkillDirectories() error = %v", err)
|
||||
}
|
||||
if len(discoveries) != 1 {
|
||||
t.Fatalf("discoveries = %#v, want 1 nested skill", discoveries)
|
||||
}
|
||||
if discoveries[0].RelativePath != "productivity/my-skill" {
|
||||
t.Fatalf("RelativePath = %q, want productivity/my-skill", discoveries[0].RelativePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverRuntimeSkillDirectoriesFlatAndNested(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
flatRoot := filepath.Join(root, "weather")
|
||||
if err := os.MkdirAll(flatRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(flatRoot, "SKILL.md"), []byte("# weather\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
discoveries, err := discoverRuntimeSkillDirectories(root, runtimeSkillDiscoveryMaxDepth)
|
||||
if err != nil {
|
||||
t.Fatalf("discoverRuntimeSkillDirectories() error = %v", err)
|
||||
}
|
||||
if len(discoveries) != 1 || discoveries[0].RelativePath != "weather" {
|
||||
t.Fatalf("discoveries = %#v, want flat weather skill", discoveries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSkillInstallRootOpenClawAndHermes(t *testing.T) {
|
||||
workspace := "/workspaces/demo/instance-1"
|
||||
hermes := &models.Instance{Type: RuntimeTypeHermes, WorkspacePath: &workspace}
|
||||
openclaw := &models.Instance{Type: RuntimeTypeOpenClaw, WorkspacePath: &workspace}
|
||||
|
||||
hermesRoot := runtimeSkillInstallRoot(hermes)
|
||||
openclawRoot := runtimeSkillInstallRoot(openclaw)
|
||||
if hermesRoot != filepath.Join(workspace, ".hermes", "skills") {
|
||||
t.Fatalf("hermes root = %q", hermesRoot)
|
||||
}
|
||||
if openclawRoot != filepath.Join(workspace, "home", ".openclaw", "workspace", "skills") {
|
||||
t.Fatalf("openclaw root = %q", openclawRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstanceSkillSourceTypePreservesInjected(t *testing.T) {
|
||||
existing := &models.InstanceSkill{SourceType: "injected_by_clawmanager"}
|
||||
skill := &models.Skill{SourceType: skillSourceUploaded}
|
||||
got := resolveInstanceSkillSourceType(existing, "discovered_in_instance", skill)
|
||||
if got != "injected_by_clawmanager" {
|
||||
t.Fatalf("resolveInstanceSkillSourceType() = %q, want injected_by_clawmanager", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeWorkspaceRelativePath(t *testing.T) {
|
||||
if got := sanitizeWorkspaceRelativePath("productivity/my-skill"); got != "productivity/my-skill" {
|
||||
t.Fatalf("sanitizeWorkspaceRelativePath() = %q", got)
|
||||
}
|
||||
if got := sanitizeWorkspaceRelativePath("../escape"); got != "" {
|
||||
t.Fatalf("sanitizeWorkspaceRelativePath(../escape) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLiteRuntimeInstanceOpenClawVariants(t *testing.T) {
|
||||
liteGateway := &models.Instance{InstanceMode: InstanceModeLite, RuntimeType: RuntimeBackendGateway, Type: RuntimeTypeOpenClaw}
|
||||
if !IsLiteRuntimeInstance(liteGateway) {
|
||||
t.Fatal("expected gateway lite instance")
|
||||
}
|
||||
proDesktop := &models.Instance{InstanceMode: InstanceModePro, RuntimeType: RuntimeBackendDesktop, Type: RuntimeTypeOpenClaw}
|
||||
if IsLiteRuntimeInstance(proDesktop) {
|
||||
t.Fatal("expected pro desktop instance to be non-lite")
|
||||
}
|
||||
shellPod := &models.Instance{InstanceMode: InstanceModePro, RuntimeType: RuntimeBackendShell, Type: RuntimeTypeOpenClaw}
|
||||
if IsLiteRuntimeInstance(shellPod) {
|
||||
t.Fatal("expected shell pod instance to use pro agent path")
|
||||
}
|
||||
}
|
||||
|
||||
type provenanceCaptureRepoStub struct {
|
||||
capturingSkillRepoStub
|
||||
upserted []*models.InstanceSkill
|
||||
}
|
||||
|
||||
func (s *provenanceCaptureRepoStub) GetBlobByContentHash(hash string) (*models.SkillBlob, error) {
|
||||
for _, blob := range s.blobs {
|
||||
if strings.EqualFold(strings.TrimSpace(blob.ContentHash), strings.TrimSpace(hash)) {
|
||||
copy := *blob
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *provenanceCaptureRepoStub) GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error) {
|
||||
for _, version := range s.versions {
|
||||
if version.SkillID == skillID && version.BlobID == blobID {
|
||||
copy := *version
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *provenanceCaptureRepoStub) UpsertInstanceSkill(item *models.InstanceSkill) error {
|
||||
copy := *item
|
||||
s.upserted = append(s.upserted, ©)
|
||||
updated := false
|
||||
for i, existing := range s.instanceSkills {
|
||||
if existing.InstanceID == item.InstanceID && existing.SkillID == item.SkillID {
|
||||
s.instanceSkills[i] = copy
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
s.instanceSkills = append(s.instanceSkills, copy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSyncAgentSkillsPreservesInjectedProvenanceAfterWorkspaceScan(t *testing.T) {
|
||||
contentHash := "abc123def456789012345678901234"
|
||||
versionID := 1
|
||||
stub := &provenanceCaptureRepoStub{
|
||||
capturingSkillRepoStub: capturingSkillRepoStub{
|
||||
skillRepoStub: skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
10: {
|
||||
ID: 10, UserID: 1, SkillKey: "ppt-1-0-0", Name: "ppt-1.0.0",
|
||||
SourceType: skillSourceUploaded, Status: skillStatusActive,
|
||||
Visibility: skillVisibilityPublic, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
1: {ID: 1, ContentHash: contentHash, ObjectKey: "hub/ppt.zip", ScanStatus: "completed"},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{
|
||||
1: {ID: 1, SkillID: 10, BlobID: 1, VersionNo: 1},
|
||||
},
|
||||
instanceSkills: []models.InstanceSkill{
|
||||
{InstanceID: 1, SkillID: 10, SourceType: "injected_by_clawmanager", Status: "active"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{
|
||||
1: {ID: 1, UserID: 1, Type: RuntimeTypeHermes, InstanceMode: InstanceModePro, RuntimeType: RuntimeBackendDesktop},
|
||||
}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{
|
||||
Mode: "full",
|
||||
Skills: []AgentSkillRecord{{
|
||||
Identifier: "ppt-1-0-0",
|
||||
ContentMD5: contentHash,
|
||||
Source: "discovered_in_instance",
|
||||
InstallPath: "home/.hermes/skills/ppt-1-0-0",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SyncAgentSkills() error = %v", err)
|
||||
}
|
||||
if len(stub.upserted) != 1 {
|
||||
t.Fatalf("upserted %d instance skills, want 1", len(stub.upserted))
|
||||
}
|
||||
if stub.upserted[0].SourceType != "injected_by_clawmanager" {
|
||||
t.Fatalf("SourceType = %q, want injected_by_clawmanager", stub.upserted[0].SourceType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAgentSkillsReusesUploadedSkillOnWorkspaceScan(t *testing.T) {
|
||||
contentHash := "abc123def456789012345678901234"
|
||||
versionID := 1
|
||||
stub := &provenanceCaptureRepoStub{
|
||||
capturingSkillRepoStub: capturingSkillRepoStub{
|
||||
skillRepoStub: skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
10: {
|
||||
ID: 10, UserID: 1, SkillKey: "ppt-1-0-0", Name: "ppt-1.0.0",
|
||||
SourceType: skillSourceUploaded, Status: skillStatusActive,
|
||||
Visibility: skillVisibilityPublic, CurrentVersionID: &versionID,
|
||||
},
|
||||
},
|
||||
blobs: map[int]*models.SkillBlob{
|
||||
1: {ID: 1, ContentHash: contentHash, ObjectKey: "hub/ppt.zip", ScanStatus: "completed"},
|
||||
},
|
||||
versions: map[int]*models.SkillVersion{
|
||||
1: {ID: 1, SkillID: 10, BlobID: 1, VersionNo: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{
|
||||
1: {ID: 1, UserID: 1, Type: RuntimeTypeHermes, InstanceMode: InstanceModeLite, RuntimeType: RuntimeBackendGateway},
|
||||
}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{
|
||||
Mode: "full",
|
||||
Skills: []AgentSkillRecord{{
|
||||
Identifier: "ppt-1-0-0",
|
||||
ContentMD5: contentHash,
|
||||
Source: "discovered_in_instance",
|
||||
InstallPath: "home/.hermes/skills/ppt-1-0-0",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SyncAgentSkills() error = %v", err)
|
||||
}
|
||||
if len(stub.createdSkills) != 0 {
|
||||
t.Fatalf("created %d discovered skills, want 0 reuse of uploaded skill", len(stub.createdSkills))
|
||||
}
|
||||
if len(stub.upserted) != 1 || stub.upserted[0].SkillID != 10 {
|
||||
t.Fatalf("upserted = %#v, want instance skill for uploaded skill id 10", stub.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingSkillResyncAgentClient struct {
|
||||
fakeRuntimeAgentClient
|
||||
calls []struct {
|
||||
instanceID int
|
||||
mode string
|
||||
}
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *recordingSkillResyncAgentClient) ResyncInstanceSkills(_ context.Context, _ string, instanceID int, mode string) error {
|
||||
c.calls = append(c.calls, struct {
|
||||
instanceID int
|
||||
mode string
|
||||
}{instanceID: instanceID, mode: mode})
|
||||
return c.err
|
||||
}
|
||||
|
||||
type inventorySyncCommandRepo struct {
|
||||
commands []models.InstanceCommand
|
||||
}
|
||||
|
||||
func (r *inventorySyncCommandRepo) Create(*models.InstanceCommand) error { return nil }
|
||||
func (r *inventorySyncCommandRepo) Update(command *models.InstanceCommand) error {
|
||||
for i := range r.commands {
|
||||
if r.commands[i].ID == command.ID {
|
||||
r.commands[i] = *command
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r.commands = append(r.commands, *command)
|
||||
return nil
|
||||
}
|
||||
func (r *inventorySyncCommandRepo) GetByID(int) (*models.InstanceCommand, error) { return nil, nil }
|
||||
func (r *inventorySyncCommandRepo) GetByInstanceIdempotencyKey(int, string) (*models.InstanceCommand, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *inventorySyncCommandRepo) GetNextPendingByInstance(int) (*models.InstanceCommand, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *inventorySyncCommandRepo) ListByInstanceID(int, int) ([]models.InstanceCommand, error) {
|
||||
return append([]models.InstanceCommand(nil), r.commands...), nil
|
||||
}
|
||||
func (r *inventorySyncCommandRepo) FindLatestFailedCollectSkillPackage(string) (*models.InstanceCommand, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRequestLiteSkillInventorySyncUsesIncrementalWhenAgentResyncFollows(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
skillsRoot := filepath.Join(workspace, "home", ".hermes", "skills")
|
||||
if err := os.MkdirAll(skillsRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stub := &capturingSkillRepoStub{
|
||||
skillRepoStub: skillRepoStub{
|
||||
skills: map[int]*models.Skill{
|
||||
10: {ID: 10, UserID: 1, SkillKey: "weather", Name: "weather", SourceType: skillSourceDiscovered, Status: skillStatusActive},
|
||||
},
|
||||
blobs: map[int]*models.SkillBlob{},
|
||||
versions: map[int]*models.SkillVersion{},
|
||||
instanceSkills: []models.InstanceSkill{
|
||||
{InstanceID: 1, SkillID: 10, Status: "active", SourceType: "discovered_in_instance"},
|
||||
},
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{
|
||||
1: {
|
||||
ID: 1, UserID: 1, Type: RuntimeTypeHermes, InstanceMode: InstanceModeLite,
|
||||
RuntimeType: RuntimeBackendGateway, WorkspacePath: &workspace, RuntimeGeneration: 1,
|
||||
},
|
||||
}}
|
||||
cmdRepo := &inventorySyncCommandRepo{commands: []models.InstanceCommand{{
|
||||
ID: 44, InstanceID: 1, CommandType: InstanceCommandTypeSyncSkillInventory, Status: instanceCommandStatusPending,
|
||||
}}}
|
||||
agent := &recordingSkillResyncAgentClient{}
|
||||
endpoint := "http://runtime-agent"
|
||||
bindingRepo := newFakeRuntimeBindingRepo()
|
||||
bindingRepo.bindings[1] = &models.InstanceRuntimeBinding{
|
||||
InstanceID: 1, RuntimePodID: 9, State: "running", Generation: 1,
|
||||
}
|
||||
podRepo := &fakeRuntimePodRepo{pods: map[int64]*models.RuntimePod{
|
||||
9: {ID: 9, AgentEndpoint: &endpoint},
|
||||
}}
|
||||
|
||||
svc := &skillService{
|
||||
repo: stub,
|
||||
instanceRepo: instRepo,
|
||||
commandRepo: cmdRepo,
|
||||
commandService: &noopInstanceCommandService{},
|
||||
}
|
||||
svc.ConfigureRuntimeSkillSync(bindingRepo, podRepo, agent)
|
||||
|
||||
if err := svc.RequestLiteSkillInventorySync(1); err != nil {
|
||||
t.Fatalf("RequestLiteSkillInventorySync() error = %v", err)
|
||||
}
|
||||
if stub.markMissingCalls != 0 {
|
||||
t.Fatalf("markMissingCalls = %d, want 0 when workspace sync is incremental", stub.markMissingCalls)
|
||||
}
|
||||
if stub.instanceSkills[0].Status != "active" {
|
||||
t.Fatalf("status = %q, want active", stub.instanceSkills[0].Status)
|
||||
}
|
||||
if len(agent.calls) != 1 || agent.calls[0].mode != "full" {
|
||||
t.Fatalf("resync calls = %#v, want one full resync", agent.calls)
|
||||
}
|
||||
if cmdRepo.commands[0].Status != instanceCommandStatusPending {
|
||||
t.Fatalf("command status = %q, want pending until agent inventory arrives", cmdRepo.commands[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLiteSkillInventorySyncPropagatesResyncError(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
skillsRoot := filepath.Join(workspace, "home", ".hermes", "skills")
|
||||
if err := os.MkdirAll(skillsRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stub := &capturingSkillRepoStub{
|
||||
skillRepoStub: skillRepoStub{
|
||||
skills: map[int]*models.Skill{},
|
||||
blobs: map[int]*models.SkillBlob{},
|
||||
versions: map[int]*models.SkillVersion{},
|
||||
instanceSkills: nil,
|
||||
},
|
||||
}
|
||||
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{
|
||||
1: {
|
||||
ID: 1, UserID: 1, Type: RuntimeTypeHermes, InstanceMode: InstanceModeLite,
|
||||
RuntimeType: RuntimeBackendGateway, WorkspacePath: &workspace, RuntimeGeneration: 1,
|
||||
},
|
||||
}}
|
||||
agent := &recordingSkillResyncAgentClient{err: errors.New("resync failed")}
|
||||
endpoint := "http://runtime-agent"
|
||||
bindingRepo := newFakeRuntimeBindingRepo()
|
||||
bindingRepo.bindings[1] = &models.InstanceRuntimeBinding{
|
||||
InstanceID: 1, RuntimePodID: 9, State: "running", Generation: 1,
|
||||
}
|
||||
podRepo := &fakeRuntimePodRepo{pods: map[int64]*models.RuntimePod{
|
||||
9: {ID: 9, AgentEndpoint: &endpoint},
|
||||
}}
|
||||
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
||||
svc.ConfigureRuntimeSkillSync(bindingRepo, podRepo, agent)
|
||||
|
||||
err := svc.RequestLiteSkillInventorySync(1)
|
||||
if err == nil || !strings.Contains(err.Error(), "resync") {
|
||||
t.Fatalf("error = %v, want resync failure", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -294,6 +294,19 @@ func TestChownRuntimePathReportsRootPermissionDenied(t *testing.T) {
|
||||
t.Fatalf("chownRuntimePath() error = %v, want owner error", err)
|
||||
}
|
||||
}
|
||||
func TestWriteSkillDirectoryAtomicallyNestedCategoryPath(t *testing.T) {
|
||||
targetRoot := t.TempDir()
|
||||
err := writeSkillDirectoryAtomically(targetRoot, "productivity/my-skill", map[string][]byte{
|
||||
"SKILL.md": []byte("# Nested Skill\n"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeSkillDirectoryAtomically() error = %v", err)
|
||||
}
|
||||
target := filepath.Join(targetRoot, "productivity", "my-skill", "SKILL.md")
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("expected nested skill directory, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
func TestWriteSkillDirectoryAtomicallyUsesNestedTempRoot(t *testing.T) {
|
||||
targetRoot := t.TempDir()
|
||||
err := writeSkillDirectoryAtomically(targetRoot, "marker-pdf-ingest", map[string][]byte{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type HubError struct {
|
||||
Code string
|
||||
Message string
|
||||
Details map[string]string
|
||||
}
|
||||
|
||||
func (e *HubError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
func NewHubError(code, message string, details map[string]string) *HubError {
|
||||
return &HubError{Code: code, Message: message, Details: details}
|
||||
}
|
||||
|
||||
// HandleHubError maps skill hub domain errors to HTTP responses.
|
||||
func HandleHubError(c *gin.Context, err error) {
|
||||
var hubErr *HubError
|
||||
if errors.As(err, &hubErr) {
|
||||
switch hubErr.Code {
|
||||
case "skill_package_md5_mismatch":
|
||||
Error(c, http.StatusBadRequest, hubErr.Code)
|
||||
default:
|
||||
Error(c, http.StatusBadRequest, hubErr.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
switch err.Error() {
|
||||
case "skill_not_scanned", "skill_risk_blocked", "skill_tags_required", "skill_not_in_library", "skill is not published to hub":
|
||||
Error(c, http.StatusBadRequest, err.Error())
|
||||
case "skill_package_pending":
|
||||
Error(c, http.StatusConflict, err.Error())
|
||||
case "skill_package_materialize_failed", "skill_package_materializing":
|
||||
Error(c, http.StatusConflict, err.Error())
|
||||
case "skill_attach_forbidden", "access denied":
|
||||
Error(c, http.StatusForbidden, err.Error())
|
||||
default:
|
||||
HandleError(c, err)
|
||||
}
|
||||
}
|
||||
@@ -72,12 +72,16 @@ func HandleError(c *gin.Context, err error) {
|
||||
Error(c, http.StatusForbidden, errStr)
|
||||
case "invalid username or password", "account is disabled", "invalid or expired agent session token":
|
||||
Error(c, http.StatusUnauthorized, errStr)
|
||||
case "agent registration is only supported for openclaw instances", "agent registration is only supported for openclaw or hermes instances", "agent id does not match session", "access denied":
|
||||
case "agent registration is only supported for openclaw instances", "agent registration is only supported for openclaw or hermes instances", "agent id does not match session", "access denied", "skill_attach_forbidden":
|
||||
Error(c, http.StatusForbidden, errStr)
|
||||
case "current password is incorrect":
|
||||
Error(c, http.StatusBadRequest, errStr)
|
||||
case "user not found", "model not found":
|
||||
case "user not found", "model not found", "skill not found", "skill hub tag not found":
|
||||
Error(c, http.StatusNotFound, errStr)
|
||||
case "skill_not_scanned", "skill_risk_blocked", "skill_tags_required", "skill is not published to hub":
|
||||
Error(c, http.StatusBadRequest, errStr)
|
||||
case "skill_package_pending", "skill_package_materialize_failed", "skill_package_materializing":
|
||||
Error(c, http.StatusConflict, err.Error())
|
||||
default:
|
||||
// For development, show actual error; for production, hide details
|
||||
Error(c, http.StatusInternalServerError, errStr)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
// FormatOpenClawSessionKey extracts the display session key from a stored session ID.
|
||||
func FormatOpenClawSessionKey(sessionID string) string {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
for _, prefix := range []string{"agent:openclaw:", "agent:hermes:"} {
|
||||
if strings.HasPrefix(sessionID, prefix) {
|
||||
return strings.TrimPrefix(sessionID, prefix)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(sessionID, "agent:") {
|
||||
parts := strings.SplitN(sessionID, ":", 3)
|
||||
if len(parts) == 3 && strings.TrimSpace(parts[2]) != "" {
|
||||
return parts[2]
|
||||
}
|
||||
}
|
||||
return sessionID
|
||||
}
|
||||
|
||||
// NormalizeOpenClawSessionID maps a runtime session key to the canonical stored session ID.
|
||||
func NormalizeOpenClawSessionID(sessionKey string, runtimeType string) string {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return sessionKey
|
||||
}
|
||||
if strings.HasPrefix(sessionKey, "agent:") {
|
||||
return sessionKey
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(runtimeType)) {
|
||||
case "hermes":
|
||||
return "agent:hermes:" + sessionKey
|
||||
default:
|
||||
return "agent:openclaw:" + sessionKey
|
||||
}
|
||||
}
|
||||
|
||||
// IsTraceFallbackSessionID reports whether a session ID was generated per trace.
|
||||
func IsTraceFallbackSessionID(sessionID string) bool {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
return strings.HasPrefix(sessionID, "sess_")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatOpenClawSessionKey(t *testing.T) {
|
||||
if got := FormatOpenClawSessionKey("agent:openclaw:main"); got != "main" {
|
||||
t.Fatalf("expected main, got %q", got)
|
||||
}
|
||||
if got := FormatOpenClawSessionKey("agent:hermes:work"); got != "work" {
|
||||
t.Fatalf("expected work, got %q", got)
|
||||
}
|
||||
if got := FormatOpenClawSessionKey("sess_trc_123"); got != "sess_trc_123" {
|
||||
t.Fatalf("expected passthrough, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenClawSessionID(t *testing.T) {
|
||||
if got := NormalizeOpenClawSessionID("main", "openclaw"); got != "agent:openclaw:main" {
|
||||
t.Fatalf("expected agent:openclaw:main, got %q", got)
|
||||
}
|
||||
if got := NormalizeOpenClawSessionID("main", "hermes"); got != "agent:hermes:main" {
|
||||
t.Fatalf("expected agent:hermes:main, got %q", got)
|
||||
}
|
||||
if got := NormalizeOpenClawSessionID("agent:openclaw:main", "openclaw"); got != "agent:openclaw:main" {
|
||||
t.Fatalf("expected unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTraceFallbackSessionID(t *testing.T) {
|
||||
if !IsTraceFallbackSessionID("sess_trc_abc") {
|
||||
t.Fatal("expected trace fallback session")
|
||||
}
|
||||
if IsTraceFallbackSessionID("agent:openclaw:main") {
|
||||
t.Fatal("expected stable session")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Optional cluster-wide reference policy for managed OpenClaw/Hermes runtimes.
|
||||
# Enable per-instance enforcement by setting CLAWMANAGER_INSTANCE_NETWORK_LOCK=true
|
||||
# on the ClawManager backend deployment.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: clawmanager-managed-runtime-egress
|
||||
namespace: clawmanager-system
|
||||
labels:
|
||||
app: clawmanager
|
||||
policy-role: managed-runtime-egress-reference
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
clawmanager.io/managed-runtime: "true"
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: clawmanager-system
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
- protocol: TCP
|
||||
port: 9001
|
||||
- protocol: TCP
|
||||
port: 3128
|
||||
@@ -22,9 +22,9 @@ stringData:
|
||||
runtime-agent-report-token: change-me-runtime-report-token
|
||||
openclaw-gateway-token: change-me-openclaw-gateway-token
|
||||
minio-root-user: minioadmin
|
||||
minio-root-password: minioadmin123
|
||||
minio-root-password: minioadmin@123
|
||||
minio-access-key: minioadmin
|
||||
minio-secret-key: minioadmin123
|
||||
minio-secret-key: minioadmin@123
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
@@ -906,6 +906,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: mysql
|
||||
#image: mysql:8.4.8
|
||||
image: mysql:8.4.8
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
@@ -1158,6 +1159,7 @@ subjects:
|
||||
name: clawmanager-app
|
||||
namespace: clawmanager-system
|
||||
---
|
||||
---
|
||||
# Explicit lease permissions for control-plane leader election. The
|
||||
# cluster-admin binding above already covers this; this Role documents the
|
||||
# requirement and keeps leader election working if RBAC is tightened later.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Optional cluster-wide reference policy for managed OpenClaw/Hermes runtimes.
|
||||
# Enable per-instance enforcement by setting CLAWMANAGER_INSTANCE_NETWORK_LOCK=true
|
||||
# on the ClawManager backend deployment.
|
||||
#
|
||||
# Per-instance policies are created automatically for managed runtimes when the
|
||||
# lock is enabled. This manifest documents the intended egress shape.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: clawmanager-managed-runtime-egress
|
||||
namespace: clawmanager-system
|
||||
labels:
|
||||
app: clawmanager
|
||||
policy-role: managed-runtime-egress-reference
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
clawmanager.io/managed-runtime: "true"
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: clawmanager-system
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
- protocol: TCP
|
||||
port: 9001
|
||||
- protocol: TCP
|
||||
port: 3128
|
||||
@@ -93,6 +93,44 @@ Agent 启动时如果 `CLAWMANAGER_AGENT_ENABLED` 不是 `true`,应进入空
|
||||
|
||||
Runtime 内的应用和 agent 如果需要调用模型,优先使用这些变量,不要让用户在镜像内手工写入 provider key。
|
||||
|
||||
### LLM Session 归因
|
||||
|
||||
托管 runtime 的每次 LLM 请求应携带稳定 session 标识,供平台按会话统计 token:
|
||||
|
||||
| 项 | 要求 |
|
||||
| --- | --- |
|
||||
| Header | `x-openclaw-session-key: {sessionKey}` |
|
||||
| 示例 | `main` 会归一化为 `agent:openclaw:main` |
|
||||
| 托管实例 Gateway Token 默认 | 未显式传 key 时,ClawManager 默认使用 `main` |
|
||||
| 备选 | 请求体 `session_id` 或 OpenAI `user` 字段 |
|
||||
| 禁止 | 长期依赖 Gateway 自动生成的 `sess_{traceID}`(用户 JWT 等非托管调用仍会 fallback) |
|
||||
|
||||
Agent state report 可选上报 LLM 配置指纹,便于平台检测配置漂移:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": {
|
||||
"llm_config_status": "gateway",
|
||||
"llm_provider_base_url": "http://clawmanager-gateway.../api/v1/gateway/llm",
|
||||
"llm_config_fingerprint": "sha256..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Egress 代理与实例归因
|
||||
|
||||
托管 runtime 实例会注入 egress 代理环境变量,并将实例 ID 写入 `CLAWMANAGER_EGRESS_INSTANCE_ID`。当 egress 拦截直连 LLM 提供商域名时,平台会把该事件记为 `egress.llm.blocked` 审计。
|
||||
|
||||
| 变量 / Header | 说明 |
|
||||
| --- | --- |
|
||||
| `HTTP_PROXY` / `HTTPS_PROXY` | 指向 ClawManager egress proxy |
|
||||
| `CLAWMANAGER_EGRESS_INSTANCE_ID` | 当前实例 ID,供代理客户端上报 |
|
||||
| `X-ClawManager-Instance-Id` | egress 请求应携带的实例 ID header(与 `X-ClawManager-Egress-Instance-Id` 等价) |
|
||||
|
||||
建议:任何从实例内主动发起的 egress CONNECT/HTTP 代理请求(包括自定义脚本、sidecar、调试工具)在可行时读取 `CLAWMANAGER_EGRESS_INSTANCE_ID` 并设置 `X-ClawManager-Instance-Id`,以便平台将 bypass 尝试关联到具体实例。
|
||||
|
||||
可选网络加固:在 ClawManager 后端设置 `CLAWMANAGER_INSTANCE_NETWORK_LOCK=true` 后,新建的 **Pro(独立 Pod)** OpenClaw/Hermes 实例会自动创建 egress NetworkPolicy。**Lite(gateway 池)** 实例共享 runtime Pod,不适用按实例 NetworkPolicy。参考 `deployments/k8s/single-node/instance-egress-networkpolicy.yaml`。
|
||||
|
||||
## Agent 生命周期
|
||||
|
||||
推荐主循环:
|
||||
@@ -342,7 +380,7 @@ Authorization: Bearer {session_token}
|
||||
| `collect_system_info` | 立即采样,发送 state report,并在 finish result 中带上同一份摘要 |
|
||||
| `health_check` | 检查主进程、桌面入口、agent、metrics collector,并发送 state report |
|
||||
| `sync_skill_inventory` | 扫描 skill 目录并上报完整 inventory |
|
||||
| `refresh_skill_inventory` | 重新扫描 skill 目录并上报完整 inventory |
|
||||
| `refresh_skill_inventory` | **已废弃**。请使用 `sync_skill_inventory` |
|
||||
| `collect_skill_package` | 打包指定 skill 并上传 |
|
||||
| `install_skill` | 下载并安装平台指定 skill version |
|
||||
| `update_skill` | 更新已安装 skill |
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# Session Token Usage
|
||||
|
||||
Instance-level reporting for LLM calls routed through the platform AI Gateway.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. Runtime or user calls `POST /api/v1/gateway/llm/chat/completions`.
|
||||
2. AI Gateway persists:
|
||||
- `model_invocations` (tokens, model, status, `instance_id`, `session_id`)
|
||||
- `cost_records` (estimated cost per trace)
|
||||
- `audit_events` (including `gateway.session.fallback` when session key is missing)
|
||||
- `chat_sessions` (optional title from first user message)
|
||||
3. Instance detail UI calls:
|
||||
- `GET /api/v1/instances/:id/session-usage`
|
||||
- `GET /api/v1/instances/:id/session-usage/detail?session_id=...`
|
||||
4. Admin overview UI calls:
|
||||
- `GET /api/v1/admin/session-usage/overview`
|
||||
|
||||
## Session ID rules
|
||||
|
||||
| Source | Stored `session_id` |
|
||||
|--------|---------------------|
|
||||
| Header `x-openclaw-session-key: main` on OpenClaw | `agent:openclaw:main` |
|
||||
| Header on Hermes | `agent:hermes:{key}` |
|
||||
| Missing stable key (user JWT or non-managed callers) | `sess_trc_{traceId}` (fallback) |
|
||||
| Missing key on **instance gateway token** for OpenClaw/Hermes | `agent:{type}:main` (managed default) |
|
||||
|
||||
Display keys are derived via `FormatOpenClawSessionKey` (e.g. `main`).
|
||||
|
||||
## API
|
||||
|
||||
### List session usage
|
||||
|
||||
`GET /api/v1/instances/:id/session-usage?page=1&limit=20&search=main&since=2026-07-01T00:00:00Z`
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `since` / `until`: optional RFC3339 timestamps (`until` must be after `since`); filter on invocation `created_at` (cost aggregates join non-blocked invocations on the same window)
|
||||
- `search`: filters session rows by session id/key/title (summary totals ignore search)
|
||||
|
||||
Response highlights:
|
||||
|
||||
- `summary`: totals across all sessions on the instance
|
||||
- `compliance`: fallback session count and recent fallback audit events
|
||||
- `items`: paginated per-session rows
|
||||
|
||||
### Session detail
|
||||
|
||||
`GET /api/v1/instances/:id/session-usage/detail?session_id=agent:openclaw:main&since=2026-07-01T00:00:00Z`
|
||||
|
||||
Accepts the same optional `since` / `until` bounds as the list endpoint. Detail rows, model breakdown, and recent traces respect the time window and exclude blocked invocations.
|
||||
|
||||
Returns model breakdown (tokens + cost) and recent traces for one session.
|
||||
|
||||
### Admin cross-instance overview
|
||||
|
||||
`GET /api/v1/admin/session-usage/overview?page=1&limit=20&search=openclaw&since=2026-07-01T00:00:00Z`
|
||||
|
||||
Returns managed OpenClaw/Hermes running instances sorted by total tokens, with per-instance summary and global totals.
|
||||
|
||||
## UI features
|
||||
|
||||
- **Time range presets**: all time, 24h, 7d, 30d (instance panel and admin overview)
|
||||
- **Auto refresh**: optional 15s polling
|
||||
- **CSV export**: instance panel exports all filtered session rows; admin page exports instance summary rows
|
||||
|
||||
## Limits
|
||||
|
||||
- **Gateway only**: direct external LLM calls that bypass the platform gateway are not included.
|
||||
- **Blocked invocations** are excluded from token aggregates.
|
||||
- Supported instance types in UI: `openclaw`, `hermes`.
|
||||
|
||||
## Database indexes
|
||||
|
||||
Migration `038_add_session_usage_indexes.sql` adds:
|
||||
|
||||
- `cost_records(instance_id)`
|
||||
- `cost_records(session_id)`
|
||||
- `model_invocations(instance_id, session_id, created_at)`
|
||||
|
||||
## Local verification
|
||||
|
||||
1. Apply migrations (including `038`).
|
||||
2. Open an OpenClaw or Hermes instance detail page (Lite or Pro).
|
||||
3. Send a gateway chat completion with `x-openclaw-session-key: main`.
|
||||
4. Refresh the **Session Token Usage** panel and confirm token totals increase.
|
||||
5. Open **Admin → AI Gateway → Session Usage** for the cross-instance overview.
|
||||
|
||||
Optional E2E:
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
npx playwright test tests/instances/session-token-tracking.spec.ts
|
||||
```
|
||||
|
||||
Requires a running stack, configured gateway models, and DB access for `fixtures/dbClient.ts`.
|
||||
|
||||
## E2E coverage
|
||||
|
||||
| Spec | Scope |
|
||||
|------|-------|
|
||||
| `session-token-tracking.spec.ts` | Instance session usage API, gateway aggregation, fallback compliance, instance gateway token |
|
||||
| `session-usage-admin.spec.ts` | Admin overview API, `since` query validation, non-admin 403 |
|
||||
|
||||
Run all session usage specs:
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
npx playwright test tests/instances/session-token-tracking.spec.ts tests/instances/session-usage-admin.spec.ts
|
||||
```
|
||||
|
||||
## Pre-commit checklist
|
||||
|
||||
When preparing the standalone session-usage PR:
|
||||
|
||||
1. Branch: `feat/session-token-usage` (from Skill Hub baseline)
|
||||
2. Include migration `038_add_session_usage_indexes.sql`
|
||||
3. Exclude unrelated WIP: egress policy, local deployment yaml, debug `_*.json` artifacts
|
||||
4. Suggested commit split:
|
||||
- `feat(session-usage): add session usage APIs, indexes, and admin overview`
|
||||
- `feat(session-usage): add instance/admin UI with filters, refresh, and CSV export`
|
||||
- `test(session-usage): add handler, service, repository, and e2e coverage`
|
||||
- `docs(session-usage): add session token usage guide`
|
||||
5. Verify locally:
|
||||
- `go test ./internal/services/... ./internal/handlers/... ./internal/repository/... -run SessionUsage`
|
||||
- Playwright P1 specs above (P0 gateway aggregation may skip when upstream LLM unavailable)
|
||||
|
||||
## Phase 9 staging file list
|
||||
|
||||
Include (session usage only):
|
||||
|
||||
**Backend**
|
||||
- `backend/cmd/server/main.go` (session-usage routes only — review diff before staging)
|
||||
- `backend/internal/db/migrations/038_add_session_usage_indexes.sql`
|
||||
- `backend/internal/db/migrations_test.go` (038 test)
|
||||
- `backend/internal/handlers/session_usage_query.go`
|
||||
- `backend/internal/handlers/session_usage_query_test.go`
|
||||
- `backend/internal/handlers/instance_handler.go`
|
||||
- `backend/internal/handlers/instance_handler_test.go`
|
||||
- `backend/internal/handlers/ai_observability_handler.go`
|
||||
- `backend/internal/handlers/ai_observability_handler_test.go`
|
||||
- `backend/internal/repository/session_usage_filter.go`
|
||||
- `backend/internal/repository/session_usage_filter_test.go`
|
||||
- `backend/internal/repository/model_invocation_repository.go`
|
||||
- `backend/internal/repository/cost_record_repository.go`
|
||||
- `backend/internal/services/ai_observability_service.go`
|
||||
- `backend/internal/services/ai_observability_session_usage_test.go`
|
||||
|
||||
**Frontend**
|
||||
- `frontend/src/components/InstanceSessionUsagePanel.tsx`
|
||||
- `frontend/src/pages/admin/SessionUsageOverviewPage.tsx`
|
||||
- `frontend/src/pages/instances/InstanceDetailPage.tsx`
|
||||
- `frontend/src/components/AdminLayout.tsx`
|
||||
- `frontend/src/pages/admin/AIGatewayPage.tsx`
|
||||
- `frontend/src/router/index.tsx`
|
||||
- `frontend/src/services/instanceService.ts`
|
||||
- `frontend/src/services/adminService.ts`
|
||||
- `frontend/src/types/instance.ts`
|
||||
- `frontend/src/utils/sessionUsageExport.ts`
|
||||
- `frontend/src/lib/i18n.ts`
|
||||
|
||||
**E2E & docs**
|
||||
- `e2e/fixtures/apiClient.ts`
|
||||
- `e2e/fixtures/dbClient.ts`
|
||||
- `e2e/tests/instances/session-token-tracking.spec.ts`
|
||||
- `e2e/tests/instances/session-usage-admin.spec.ts`
|
||||
- `docs/session-token-usage.md`
|
||||
|
||||
Exclude (do not stage for session-usage PR):
|
||||
|
||||
- `backend/internal/egresspolicy/**`
|
||||
- `backend/internal/handlers/egress_proxy_handler*.go`
|
||||
- `deployments/k8s/**/instance-egress-networkpolicy.yaml`
|
||||
- `deployments/scripts/**`
|
||||
- `e2e/tests/instances/llm-governance.spec.ts` (unless bundled intentionally)
|
||||
- Root `_*.json`, `pr138.patch`, debug artifacts
|
||||
@@ -132,3 +132,13 @@ weather/
|
||||
Hermes agent 在本地计算 MD5 时应对目录 `/config/.hermes/skills/weather` 调用 `skill_content_md5()`。不要对 zip 文件调用 MD5。
|
||||
|
||||
如果 agent 需要在上传前自检,可以先把 zip 解开,确认去掉 `weather/` 后得到的文件列表与本地计算使用的相对路径一致。
|
||||
|
||||
## 常见 MD5 不匹配排查
|
||||
|
||||
| 现象 | 可能原因 | 处理 |
|
||||
|------|----------|------|
|
||||
| `expected abc got def` 且 expected 来自 inventory | Agent inventory 与 collect 使用了不同目录快照 | 确保 collect 前目录未变化,且两次调用同一 `skill_content_md5()` |
|
||||
| got 值每次不同 | 对 zip 文件 bytes 做 MD5,或 zip 内文件顺序/元数据参与计算 | 只对 skill 目录内容做规范化 MD5,见上文算法 |
|
||||
| expected 含顶层目录名 | inventory 多剥了一层 skill 根目录 | 基准目录应为 `/config/.openclaw/workspace/skills/{name}/` 内部 |
|
||||
| 隐藏文件导致偏差 | `.git`、`.cache` 等被计入或遗漏不一致 | 跳过任意以 `.` 开头的路径段 |
|
||||
| collect 命令 succeeded 但 Hub 仍 unknown | skill-scanner 未部署或扫描失败 | 检查 `skill-scanner` Pod 与 blob `scan_status` |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Lite Skill Package Materialization
|
||||
|
||||
Lite (gateway / Hermes) instances discover skills from the shared workspace instead of
|
||||
using the instance agent `collect_skill_package` command.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Inventory** — `syncLiteSkillsFromWorkspace` or runtime agent report calls
|
||||
`SyncAgentSkills`, which upserts skills and writes `instance_skills.workspace_dir`.
|
||||
2. **Enqueue** — For Lite instances with empty `skill_blobs.object_key`, ClawManager
|
||||
inserts a row into `skill_package_materialize_jobs` (never `collect_skill_package`).
|
||||
3. **Materialize** — The leader-only `SkillPackageMaterializeWorker` reads workspace
|
||||
directories, builds a normalized ZIP, uploads to MinIO, and runs skill-scanner.
|
||||
4. **Publish** — Once `object_key` is set and scan completes, skills can be imported
|
||||
to the library and published to Skill Hub.
|
||||
|
||||
## Paths
|
||||
|
||||
| Runtime | Workspace skill root |
|
||||
|---------|---------------------|
|
||||
| Hermes Lite | `{workspace}/home/.hermes/skills/{name}` |
|
||||
| OpenClaw Lite | `{workspace}/home/.openclaw/workspace/skills/{name}` |
|
||||
|
||||
The authoritative directory name is stored in `instance_skills.workspace_dir`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment variable | Default | Description |
|
||||
|------------------------|---------|-------------|
|
||||
| `SKILL_MATERIALIZE_WORKER_ENABLED` | `true` | Enable background worker |
|
||||
| `SKILL_MATERIALIZE_TICK_MS` | `2000` | Worker poll interval |
|
||||
| `SKILL_MATERIALIZE_BATCH_SIZE` | `5` | Jobs claimed per tick |
|
||||
| `SKILL_MATERIALIZE_CONCURRENCY` | `5` | Global worker concurrency |
|
||||
| `SKILL_MATERIALIZE_PER_INSTANCE_CONCURRENCY` | `2` | Max parallel jobs per instance |
|
||||
|
||||
## Agent commands
|
||||
|
||||
Pro and Shell instances still use `collect_skill_package` via the instance agent.
|
||||
Lite instances **do not**; package collection is server-side only.
|
||||
|
||||
For Lite inventory, ClawManager treats the shared workspace scan as the authoritative
|
||||
`content_md5` source. Runtime agent reports may differ; server-side materialize always
|
||||
recomputes from workspace and self-heals stale blob hashes instead of failing with
|
||||
`skill package md5 mismatch`.
|
||||
|
||||
## Backfill
|
||||
|
||||
On worker start, pending Lite blobs with `workspace_dir` set are enqueued automatically.
|
||||
Migration `039_add_skill_package_materialize.sql` also cancels stale Lite
|
||||
`collect_skill_package` commands and backfills `workspace_dir` from `install_path`.
|
||||
|
||||
## Hub UI blocked reasons
|
||||
|
||||
When enriching Skill Hub payloads without an explicit instance (catalog, "My Skills",
|
||||
detail pages), ClawManager resolves a Lite instance from active `instance_skills` rows
|
||||
for that skill. This prevents stale Pro-only `collect_skill_package` failures from
|
||||
showing as `skill_package_collect_failed` on Lite-discovered skills.
|
||||
@@ -396,3 +396,139 @@ export async function disableExternalAccess(
|
||||
});
|
||||
await expectOkEnvelope<null>(response);
|
||||
}
|
||||
|
||||
export interface GatewayModelSummary {
|
||||
id: number | string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export async function listGatewayModels(
|
||||
request: APIRequestContext,
|
||||
accessToken: string,
|
||||
): Promise<GatewayModelSummary[]> {
|
||||
const response = await request.get(`${env.backendUrl}/gateway/llm/models`, {
|
||||
headers: bearer(accessToken),
|
||||
});
|
||||
const body = await expectOkEnvelope<{ items: GatewayModelSummary[] }>(response);
|
||||
return body.items ?? [];
|
||||
}
|
||||
|
||||
export async function gatewayChatCompletion(
|
||||
request: APIRequestContext,
|
||||
accessToken: string,
|
||||
payload: {
|
||||
model: string;
|
||||
instance_id?: number;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
stream?: boolean;
|
||||
},
|
||||
extraHeaders: Record<string, string> = {},
|
||||
) {
|
||||
return request.post(`${env.backendUrl}/gateway/llm/chat/completions`, {
|
||||
headers: {
|
||||
...bearer(accessToken),
|
||||
...extraHeaders,
|
||||
},
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export interface LLMGovernanceOverview {
|
||||
total_managed_instances: number;
|
||||
non_compliant_count: number;
|
||||
external_config_count: number;
|
||||
high_fallback_count: number;
|
||||
items: Array<{
|
||||
instance_id: number;
|
||||
instance_name: string;
|
||||
is_compliant: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function getLLMGovernanceOverview(
|
||||
request: APIRequestContext,
|
||||
accessToken: string,
|
||||
): Promise<LLMGovernanceOverview> {
|
||||
const response = await request.get(`${env.backendUrl}/admin/llm-governance/overview`, {
|
||||
headers: bearer(accessToken),
|
||||
});
|
||||
return expectOkEnvelope<LLMGovernanceOverview>(response);
|
||||
}
|
||||
|
||||
export interface InstanceSessionUsageResult {
|
||||
summary: {
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
total_tokens: number;
|
||||
total_estimated_cost: number;
|
||||
currency: string;
|
||||
session_count: number;
|
||||
};
|
||||
compliance: {
|
||||
fallback_session_count: number;
|
||||
has_fallback_sessions: boolean;
|
||||
recent_fallback_audit_count: number;
|
||||
};
|
||||
items: Array<{
|
||||
session_id: string;
|
||||
session_key: string;
|
||||
total_tokens: number;
|
||||
invocation_count: number;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface SessionUsageOverviewResult {
|
||||
summary: {
|
||||
total_tokens: number;
|
||||
total_estimated_cost: number;
|
||||
currency: string;
|
||||
session_count: number;
|
||||
};
|
||||
items: Array<{
|
||||
instance_id: number;
|
||||
instance_name: string;
|
||||
instance_type: string;
|
||||
user_id: number;
|
||||
summary: {
|
||||
total_tokens: number;
|
||||
session_count: number;
|
||||
total_estimated_cost: number;
|
||||
currency: string;
|
||||
};
|
||||
compliance: {
|
||||
fallback_session_count: number;
|
||||
has_fallback_sessions: boolean;
|
||||
};
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export async function getInstanceSessionUsage(
|
||||
request: APIRequestContext,
|
||||
accessToken: string,
|
||||
instanceId: number,
|
||||
params?: { page?: number; limit?: number; search?: string; since?: string; until?: string },
|
||||
): Promise<InstanceSessionUsageResult> {
|
||||
const response = await request.get(`${env.backendUrl}/instances/${instanceId}/session-usage`, {
|
||||
headers: bearer(accessToken),
|
||||
params,
|
||||
});
|
||||
return expectOkEnvelope<InstanceSessionUsageResult>(response);
|
||||
}
|
||||
|
||||
export async function getAdminSessionUsageOverview(
|
||||
request: APIRequestContext,
|
||||
accessToken: string,
|
||||
params?: { page?: number; limit?: number; search?: string; since?: string; until?: string },
|
||||
): Promise<SessionUsageOverviewResult> {
|
||||
const response = await request.get(`${env.backendUrl}/admin/session-usage/overview`, {
|
||||
headers: bearer(accessToken),
|
||||
params,
|
||||
});
|
||||
return expectOkEnvelope<SessionUsageOverviewResult>(response);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import mysql from "mysql2/promise";
|
||||
import { env } from "./env.js";
|
||||
|
||||
export async function getInstanceGatewayToken(instanceId: number): Promise<string | null> {
|
||||
const connection = await mysql.createConnection({
|
||||
host: env.db.host,
|
||||
port: env.db.port,
|
||||
user: env.db.user,
|
||||
password: env.db.password,
|
||||
database: env.db.database,
|
||||
});
|
||||
try {
|
||||
const [rows] = await connection.query<{ access_token: string | null }[]>(
|
||||
"SELECT access_token FROM instances WHERE id = ? LIMIT 1",
|
||||
[instanceId],
|
||||
);
|
||||
const row = rows[0];
|
||||
const token = row?.access_token?.trim();
|
||||
return token ? token : null;
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, test } from "../../fixtures/test.js";
|
||||
import { env } from "../../fixtures/env.js";
|
||||
import { login, getLLMGovernanceOverview } from "../../fixtures/apiClient.js";
|
||||
import { users } from "../../fixtures/users.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function egressProxyOrigin(): string {
|
||||
return env.backendUrl.replace(/\/api\/v1\/?$/, "");
|
||||
}
|
||||
|
||||
test("@p2 create openclaw instance rejects protected env override", async ({ request }) => {
|
||||
const tokens = await login(request, users.admin);
|
||||
const suffix = Date.now();
|
||||
|
||||
const response = await request.post(`${env.backendUrl}/instances`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
data: {
|
||||
name: `e2e-governance-${suffix}`,
|
||||
type: "openclaw",
|
||||
mode: "lite",
|
||||
cpu_cores: 1,
|
||||
memory_gb: 2,
|
||||
disk_gb: 20,
|
||||
gpu_enabled: false,
|
||||
gpu_count: 0,
|
||||
os_type: "openclaw",
|
||||
os_version: "latest",
|
||||
environment_overrides: {
|
||||
OPENAI_BASE_URL: "https://api.openai.com/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBeGreaterThanOrEqual(400);
|
||||
const body = (await response.json()) as ApiEnvelope<unknown>;
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error ?? "").toMatch(/managed by the platform/i);
|
||||
});
|
||||
|
||||
test("@p2 batch lite create rejects protected env override", async ({ request }) => {
|
||||
const tokens = await login(request, users.admin);
|
||||
const suffix = Date.now();
|
||||
|
||||
const response = await request.post(`${env.backendUrl}/instances/batch/lite`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
data: {
|
||||
name_prefix: `e2e-batch-gov-${suffix}`,
|
||||
count: 1,
|
||||
template: {
|
||||
type: "openclaw",
|
||||
environment_overrides: {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBeGreaterThanOrEqual(400);
|
||||
const body = (await response.json()) as ApiEnvelope<unknown>;
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error ?? "").toMatch(/managed by the platform/i);
|
||||
});
|
||||
|
||||
test("@p2 admin llm governance overview returns managed runtime summary", async ({ request }) => {
|
||||
const tokens = await login(request, users.admin);
|
||||
const overview = await getLLMGovernanceOverview(request, tokens.access_token);
|
||||
|
||||
expect(typeof overview.total_managed_instances).toBe("number");
|
||||
expect(Array.isArray(overview.items)).toBe(true);
|
||||
});
|
||||
|
||||
test("@p2 @local-only egress proxy blocks direct openai connect", async () => {
|
||||
let statusCode = "";
|
||||
try {
|
||||
statusCode = execFileSync(
|
||||
"curl",
|
||||
[
|
||||
"-x",
|
||||
egressProxyOrigin(),
|
||||
"-H",
|
||||
"X-ClawManager-Egress-Instance-Id: 1",
|
||||
"-m",
|
||||
"5",
|
||||
"-s",
|
||||
"-o",
|
||||
process.platform === "win32" ? "NUL" : "/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"https://api.openai.com",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
} catch {
|
||||
test.skip(true, "curl unavailable or egress proxy not reachable");
|
||||
}
|
||||
|
||||
expect(statusCode).toBe("403");
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { expect, test } from "../../fixtures/test.js";
|
||||
import { env } from "../../fixtures/env.js";
|
||||
import {
|
||||
gatewayChatCompletion,
|
||||
getInstanceSessionUsage,
|
||||
listGatewayModels,
|
||||
listInstances,
|
||||
login,
|
||||
} from "../../fixtures/apiClient.js";
|
||||
import { getInstanceGatewayToken } from "../../fixtures/dbClient.js";
|
||||
import { users } from "../../fixtures/users.js";
|
||||
|
||||
function firstOpenClawInstance(instances: Awaited<ReturnType<typeof listInstances>>) {
|
||||
return instances.instances.find(
|
||||
(instance) =>
|
||||
instance.type === "openclaw" &&
|
||||
instance.status !== "deleting",
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchSessionUsage(
|
||||
request: Parameters<typeof login>[0],
|
||||
accessToken: string,
|
||||
instanceId: number,
|
||||
) {
|
||||
return getInstanceSessionUsage(request, accessToken, instanceId, { page: 1, limit: 50 });
|
||||
}
|
||||
|
||||
function isGatewaySuccessStatus(status: number): boolean {
|
||||
return status === 200 || status === 201;
|
||||
}
|
||||
|
||||
test("@p1 session usage endpoint returns structured payload for openclaw instance", async ({
|
||||
request,
|
||||
}) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const instances = await listInstances(request, accessToken, { limit: 100 });
|
||||
const instance = firstOpenClawInstance(instances);
|
||||
test.skip(!instance, "No openclaw instance available for session usage test");
|
||||
|
||||
const data = await fetchSessionUsage(request, accessToken, instance!.id);
|
||||
expect(data.summary).toBeTruthy();
|
||||
expect(Array.isArray(data.items)).toBe(true);
|
||||
expect(typeof data.total).toBe("number");
|
||||
expect(typeof data.compliance.recent_fallback_audit_count).toBe("number");
|
||||
});
|
||||
|
||||
test("@p1 session usage detail requires session_id", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const instances = await listInstances(request, accessToken, { limit: 100 });
|
||||
const instance = firstOpenClawInstance(instances);
|
||||
test.skip(!instance, "No openclaw instance available for session usage detail test");
|
||||
|
||||
const response = await request.get(
|
||||
`${env.backendUrl}/instances/${instance!.id}/session-usage/detail`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test("@p0 gateway calls aggregate tokens by openclaw session key", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const instances = await listInstances(request, accessToken, { limit: 100 });
|
||||
const instance = firstOpenClawInstance(instances);
|
||||
test.skip(!instance, "No openclaw instance available for gateway aggregation test");
|
||||
|
||||
const models = await listGatewayModels(request, accessToken);
|
||||
test.skip(models.length === 0, "No gateway models configured for session aggregation test");
|
||||
|
||||
const baseline = await fetchSessionUsage(request, accessToken, instance!.id);
|
||||
const baselineMain = baseline.items.find((item) => item.session_key === "main");
|
||||
let successfulCalls = 0;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const response = await gatewayChatCompletion(request, accessToken, {
|
||||
model: "auto",
|
||||
instance_id: instance!.id,
|
||||
messages: [{ role: "user", content: `session aggregation probe ${Date.now()}-${attempt}` }],
|
||||
}, {
|
||||
"x-openclaw-session-key": "main",
|
||||
"x-openclaw-run-id": `e2e-session-${Date.now()}-${attempt}`,
|
||||
});
|
||||
if (isGatewaySuccessStatus(response.status())) {
|
||||
successfulCalls += 1;
|
||||
}
|
||||
}
|
||||
test.skip(successfulCalls === 0, "Gateway upstream unavailable; skipping token aggregation assertion");
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await fetchSessionUsage(request, accessToken, instance!.id);
|
||||
const main = latest.items.find((item) => item.session_key === "main");
|
||||
return main?.invocation_count ?? 0;
|
||||
}, { timeout: 20_000 })
|
||||
.toBeGreaterThan(baselineMain?.invocation_count ?? 0);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await fetchSessionUsage(request, accessToken, instance!.id);
|
||||
const main = latest.items.find((item) => item.session_key === "main");
|
||||
return main?.total_tokens ?? 0;
|
||||
}, { timeout: 20_000 })
|
||||
.toBeGreaterThan(baselineMain?.total_tokens ?? 0);
|
||||
});
|
||||
|
||||
test("@p0 gateway missing session key surfaces fallback compliance", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const instances = await listInstances(request, accessToken, { limit: 100 });
|
||||
const instance = firstOpenClawInstance(instances);
|
||||
test.skip(!instance, "No openclaw instance available for fallback compliance test");
|
||||
|
||||
const models = await listGatewayModels(request, accessToken);
|
||||
test.skip(models.length === 0, "No gateway models configured for fallback compliance test");
|
||||
|
||||
const baseline = await fetchSessionUsage(request, accessToken, instance!.id);
|
||||
const response = await gatewayChatCompletion(request, accessToken, {
|
||||
model: "auto",
|
||||
instance_id: instance!.id,
|
||||
messages: [{ role: "user", content: `fallback probe ${Date.now()}` }],
|
||||
}, {
|
||||
"x-openclaw-run-id": `e2e-fallback-${Date.now()}`,
|
||||
});
|
||||
test.skip(!isGatewaySuccessStatus(response.status()), "Gateway upstream unavailable; skipping fallback assertion");
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await fetchSessionUsage(request, accessToken, instance!.id);
|
||||
return latest.compliance.has_fallback_sessions;
|
||||
}, { timeout: 20_000 })
|
||||
.toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await fetchSessionUsage(request, accessToken, instance!.id);
|
||||
return latest.compliance.recent_fallback_audit_count;
|
||||
}, { timeout: 20_000 })
|
||||
.toBeGreaterThan(baseline.compliance.recent_fallback_audit_count);
|
||||
});
|
||||
|
||||
test("@p0 instance gateway token can call chat completions", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const instances = await listInstances(request, accessToken, { limit: 100 });
|
||||
const instance = firstOpenClawInstance(instances);
|
||||
test.skip(!instance, "No openclaw instance available for instance gateway token test");
|
||||
|
||||
let gatewayToken: string | null = null;
|
||||
try {
|
||||
gatewayToken = await getInstanceGatewayToken(instance!.id);
|
||||
} catch {
|
||||
test.skip(true, "E2E database unavailable for instance gateway token lookup");
|
||||
}
|
||||
test.skip(!gatewayToken, "Instance gateway token not provisioned");
|
||||
|
||||
const models = await listGatewayModels(request, gatewayToken);
|
||||
test.skip(models.length === 0, "No gateway models configured for instance token test");
|
||||
|
||||
const response = await gatewayChatCompletion(request, gatewayToken, {
|
||||
model: "auto",
|
||||
messages: [{ role: "user", content: `instance token probe ${Date.now()}` }],
|
||||
}, {
|
||||
"x-openclaw-session-key": "main",
|
||||
"x-openclaw-run-id": `e2e-instance-token-${Date.now()}`,
|
||||
});
|
||||
expect([200, 201]).toContain(response.status());
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "../../fixtures/test.js";
|
||||
import { env } from "../../fixtures/env.js";
|
||||
import {
|
||||
getAdminSessionUsageOverview,
|
||||
getInstanceSessionUsage,
|
||||
listInstances,
|
||||
login,
|
||||
registerUser,
|
||||
} from "../../fixtures/apiClient.js";
|
||||
import { users } from "../../fixtures/users.js";
|
||||
|
||||
function firstManagedRuntimeInstance(instances: Awaited<ReturnType<typeof listInstances>>) {
|
||||
return instances.instances.find(
|
||||
(instance) =>
|
||||
(instance.type === "openclaw" || instance.type === "hermes") &&
|
||||
instance.status !== "deleting",
|
||||
);
|
||||
}
|
||||
|
||||
test("@p1 admin session usage overview returns structured payload", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const overview = await getAdminSessionUsageOverview(request, accessToken, {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(overview.summary).toBeTruthy();
|
||||
expect(Array.isArray(overview.items)).toBe(true);
|
||||
expect(typeof overview.total).toBe("number");
|
||||
expect(typeof overview.page).toBe("number");
|
||||
expect(typeof overview.limit).toBe("number");
|
||||
});
|
||||
|
||||
test("@p1 admin session usage overview rejects invalid since timestamp", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const response = await request.get(`${env.backendUrl}/admin/session-usage/overview`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
params: { since: "not-a-date" },
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test("@p1 instance session usage accepts since query parameter", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const instances = await listInstances(request, accessToken, { limit: 100 });
|
||||
const instance = firstManagedRuntimeInstance(instances);
|
||||
test.skip(!instance, "No openclaw/hermes instance available for since-filter test");
|
||||
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
const data = await getInstanceSessionUsage(request, accessToken, instance!.id, {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
since,
|
||||
});
|
||||
|
||||
expect(data.summary).toBeTruthy();
|
||||
expect(Array.isArray(data.items)).toBe(true);
|
||||
});
|
||||
|
||||
test("@p1 instance session usage rejects invalid since timestamp", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const instances = await listInstances(request, accessToken, { limit: 100 });
|
||||
const instance = firstManagedRuntimeInstance(instances);
|
||||
test.skip(!instance, "No openclaw/hermes instance available for invalid since test");
|
||||
|
||||
const response = await request.get(
|
||||
`${env.backendUrl}/instances/${instance!.id}/session-usage`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
params: { since: "bad-timestamp" },
|
||||
},
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test("@p2 non-admin cannot access session usage overview", async ({ request }) => {
|
||||
await registerUser(request, users.user);
|
||||
const accessToken = (await login(request, users.user)).access_token;
|
||||
const response = await request.get(`${env.backendUrl}/admin/session-usage/overview`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
expect(response.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("@p1 session usage rejects until before since", async ({ request }) => {
|
||||
const accessToken = (await login(request, users.admin)).access_token;
|
||||
const response = await request.get(`${env.backendUrl}/admin/session-usage/overview`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
params: {
|
||||
since: "2026-07-10T00:00:00Z",
|
||||
until: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
@@ -74,7 +74,7 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children, title = '' }) => {
|
||||
path: '/admin/ai-gateway',
|
||||
label: t('nav.aiGateway'),
|
||||
icon: Bot,
|
||||
matchPaths: ['/admin/models', '/admin/ai-audit', '/admin/costs', '/admin/risk-rules'],
|
||||
matchPaths: ['/admin/models', '/admin/ai-audit', '/admin/costs', '/admin/risk-rules', '/admin/session-usage'],
|
||||
},
|
||||
{ path: '/admin/settings', label: t('nav.settings'), icon: Settings },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useI18n } from "../contexts/I18nContext";
|
||||
|
||||
type Props = {
|
||||
storageKey: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
defaultCollapsed?: boolean;
|
||||
summary?: React.ReactNode;
|
||||
headerActions?: React.ReactNode;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
contentClassName?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function readStoredCollapsed(storageKey: string, defaultCollapsed: boolean): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored === "true") {
|
||||
return true;
|
||||
}
|
||||
if (stored === "false") {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
return defaultCollapsed;
|
||||
}
|
||||
|
||||
export default function InstanceCollapsiblePanel({
|
||||
storageKey,
|
||||
title,
|
||||
icon,
|
||||
defaultCollapsed = true,
|
||||
summary,
|
||||
headerActions,
|
||||
onExpandedChange,
|
||||
contentClassName,
|
||||
children,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [collapsed, setCollapsed] = useState(() => readStoredCollapsed(storageKey, defaultCollapsed));
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed((current) => {
|
||||
const next = !current;
|
||||
onExpandedChange?.(!next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onExpandedChange?.(!collapsed);
|
||||
}, [collapsed, onExpandedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(collapsed));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}, [collapsed, storageKey]);
|
||||
|
||||
return (
|
||||
<section className={`cm-surface shrink-0 px-4 ${collapsed ? "py-2" : "py-3"}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex min-w-0 flex-1 items-start gap-2 text-left"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-slate-950">{title}</h2>
|
||||
{collapsed ? (
|
||||
<ChevronDown className="h-4 w-4 text-slate-400" aria-hidden />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-slate-400" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
{collapsed && summary ? (
|
||||
<span className="mt-1 block text-xs text-slate-500">{summary}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!collapsed ? headerActions : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="rounded-md border border-slate-200 px-2 py-1 text-xs text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{collapsed ? t("instances.panelExpand") : t("instances.panelCollapse")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed ? (
|
||||
<div className={contentClassName ?? "mt-3"}>{children}</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user