Files
yuan-lab-llm--clawmanager/backend/internal/services/skill_package_materialize_service.go
T
heranran 71c7672545 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>
2026-07-17 12:37:01 +08:00

299 lines
9.3 KiB
Go

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
}
}