Files
multica/server/internal/daemon/skill_cache.go
Bohan Jiang 5038c983c0 MUL-3281: Add daemon skill bundle refs (#4445)
* feat: add daemon skill bundle refs

Co-authored-by: multica-agent <github@multica.ai>

* fix: tighten skill bundle resolve safeguards

Co-authored-by: multica-agent <github@multica.ai>

* feat: add task prepare lease

Co-authored-by: multica-agent <github@multica.ai>

* fix: isolate prepare lease concurrent index migration

Co-authored-by: multica-agent <github@multica.ai>

* fix: keep prepare lease active through start

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 16:19:16 +08:00

174 lines
4.0 KiB
Go

package daemon
import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"sync"
"github.com/multica-ai/multica/server/pkg/skillbundle"
)
type SkillBundleCache struct {
root string
mu sync.Mutex
locks map[string]*sync.Mutex
}
func NewSkillBundleCache(root string) *SkillBundleCache {
return &SkillBundleCache{root: root, locks: make(map[string]*sync.Mutex)}
}
func (c *SkillBundleCache) Load(workspaceID string, ref SkillRefData) (SkillData, bool) {
if c == nil || c.root == "" {
return SkillData{}, false
}
keyPath := c.bundlePath(workspaceID, ref)
data, err := os.ReadFile(keyPath)
if err != nil {
return SkillData{}, false
}
var bundle SkillData
if err := json.Unmarshal(data, &bundle); err != nil || !validateSkillBundle(ref, bundle) {
_ = os.Remove(keyPath)
return SkillData{}, false
}
return bundle, true
}
func (c *SkillBundleCache) Store(workspaceID string, bundle SkillData) error {
if c == nil || c.root == "" {
return nil
}
ref := SkillRefData{ID: bundle.ID, Source: bundle.Source, Hash: bundle.Hash}
dir := filepath.Dir(c.bundlePath(workspaceID, ref))
tmp, err := os.MkdirTemp(filepath.Dir(dir), ".bundle-*")
if err != nil {
if mkErr := os.MkdirAll(filepath.Dir(dir), 0o755); mkErr != nil {
return mkErr
}
tmp, err = os.MkdirTemp(filepath.Dir(dir), ".bundle-*")
if err != nil {
return err
}
}
defer os.RemoveAll(tmp)
data, err := json.Marshal(bundle)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(tmp, "bundle.json"), data, 0o644); err != nil {
return err
}
_ = os.RemoveAll(dir)
if err := os.Rename(tmp, dir); err != nil {
return err
}
return nil
}
func (c *SkillBundleCache) WithRefLock(workspaceID string, ref SkillRefData, fn func() error) error {
if c == nil {
return fn()
}
key := workspaceID + "\x00" + ref.Source + "\x00" + ref.ID + "\x00" + ref.Hash
lock := c.lockForKey(key)
lock.Lock()
defer lock.Unlock()
return fn()
}
func (c *SkillBundleCache) lockForKey(key string) *sync.Mutex {
c.mu.Lock()
defer c.mu.Unlock()
if lock := c.locks[key]; lock != nil {
return lock
}
lock := &sync.Mutex{}
c.locks[key] = lock
return lock
}
func (c *SkillBundleCache) bundlePath(workspaceID string, ref SkillRefData) string {
return filepath.Join(
c.root,
safeCacheSegment(workspaceID),
safeCacheSegment(ref.Source),
safeCacheSegment(ref.ID),
safeCacheSegment(ref.Hash),
"bundle.json",
)
}
func validateSkillBundle(ref SkillRefData, bundle SkillData) bool {
if bundle.ID != ref.ID || bundle.Source != ref.Source || bundle.Hash != ref.Hash {
return false
}
if len(bundle.Files) != ref.FileCount {
return false
}
files := make([]skillbundle.File, 0, len(bundle.Files))
for _, file := range bundle.Files {
if !safeSkillFilePath(file.Path) {
return false
}
files = append(files, skillbundle.File{Path: file.Path, Content: file.Content})
}
manifest := skillbundle.BuildManifest(skillbundle.Skill{
ID: bundle.ID,
Source: bundle.Source,
Name: bundle.Name,
Description: bundle.Description,
Content: bundle.Content,
Files: files,
})
if manifest.Hash != ref.Hash {
return false
}
if ref.SizeBytes > 0 && manifest.SizeBytes != ref.SizeBytes {
return false
}
return true
}
func safeSkillFilePath(p string) bool {
if p == "" || strings.Contains(p, "\x00") || strings.HasPrefix(p, "/") || strings.Contains(p, "\\") {
return false
}
clean := path.Clean(p)
if clean == "." || clean != p || strings.HasPrefix(clean, "../") || clean == ".." {
return false
}
return true
}
func safeCacheSegment(s string) string {
var b strings.Builder
if s == "" {
return "_"
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_' || r == '.':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
if out == "." || out == ".." {
return fmt.Sprintf("_%s", out)
}
return out
}