Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cmd/picoclaw/cmd_gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,13 @@ func gatewayCmd() {
return tools.SilentResult(response)
})

// Create media store for file lifecycle management
mediaStore := media.NewFileMediaStore()
// Create media store for file lifecycle management with TTL cleanup
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
})
mediaStore.Start()

channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
if err != nil {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there err path not close the goroutine?

Expand Down Expand Up @@ -200,6 +205,7 @@ func gatewayCmd() {
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
mediaStore.Stop()
agentLoop.Stop()
fmt.Println("✓ Gateway stopped")
}
Expand Down
15 changes: 11 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,11 +504,18 @@ type ExecConfig struct {
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
}

type MediaCleanupConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
}

type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"`
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
}

type SkillsToolsConfig struct {
Expand Down
5 changes: 5 additions & 0 deletions pkg/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ func DefaultConfig() *Config {
Port: 18790,
},
Tools: ToolsConfig{
MediaCleanup: MediaCleanupConfig{
Enabled: true,
MaxAge: 30,
Interval: 5,
},
Web: WebToolsConfig{
Brave: BraveConfig{
Enabled: false,
Expand Down
109 changes: 105 additions & 4 deletions pkg/media/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package media

import (
"fmt"
"log"
"os"
"sync"
"time"

"github.com/google/uuid"
)
Expand Down Expand Up @@ -35,8 +37,16 @@ type MediaStore interface {

// mediaEntry holds the path and metadata for a stored media file.
type mediaEntry struct {
path string
meta MediaMeta
path string
meta MediaMeta
storedAt time.Time
}

// MediaCleanerConfig configures the background TTL cleanup.
type MediaCleanerConfig struct {
Enabled bool
MaxAge time.Duration
Interval time.Duration
}

// FileMediaStore is a pure in-memory implementation of MediaStore.
Expand All @@ -45,13 +55,33 @@ type FileMediaStore struct {
mu sync.RWMutex
refs map[string]mediaEntry
scopeToRefs map[string]map[string]struct{}
refToScope map[string]string

cleanerCfg MediaCleanerConfig
stop chan struct{}
once sync.Once
nowFunc func() time.Time // for testing
}

// NewFileMediaStore creates a new FileMediaStore.
// NewFileMediaStore creates a new FileMediaStore without background cleanup.
func NewFileMediaStore() *FileMediaStore {
return &FileMediaStore{
refs: make(map[string]mediaEntry),
scopeToRefs: make(map[string]map[string]struct{}),
refToScope: make(map[string]string),
nowFunc: time.Now,
}
}

// NewFileMediaStoreWithCleanup creates a FileMediaStore with TTL-based background cleanup.
func NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore {
return &FileMediaStore{
refs: make(map[string]mediaEntry),
scopeToRefs: make(map[string]map[string]struct{}),
refToScope: make(map[string]string),
cleanerCfg: cfg,
stop: make(chan struct{}),
nowFunc: time.Now,
}
}

Expand All @@ -66,11 +96,12 @@ func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (
s.mu.Lock()
defer s.mu.Unlock()

s.refs[ref] = mediaEntry{path: localPath, meta: meta}
s.refs[ref] = mediaEntry{path: localPath, meta: meta, storedAt: s.nowFunc()}
if s.scopeToRefs[scope] == nil {
s.scopeToRefs[scope] = make(map[string]struct{})
}
s.scopeToRefs[scope][ref] = struct{}{}
s.refToScope[ref] = scope

return ref, nil
}
Expand Down Expand Up @@ -115,9 +146,79 @@ func (s *FileMediaStore) ReleaseAll(scope string) error {
// Log but continue — best effort cleanup
}
delete(s.refs, ref)
delete(s.refToScope, ref)
}
}

delete(s.scopeToRefs, scope)
return nil
}

// CleanExpired removes all entries older than MaxAge.
// Both the file on disk and the in-memory references are deleted atomically
// under the same mutex, preventing dangling references.
func (s *FileMediaStore) CleanExpired() int {
s.mu.Lock()
defer s.mu.Unlock()

cutoff := s.nowFunc().Add(-s.cleanerCfg.MaxAge)
removed := 0

for ref, entry := range s.refs {
if entry.storedAt.Before(cutoff) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and should cutoff >= 0 ?

if err := os.Remove(entry.path); err != nil && !os.IsNotExist(err) {
// Log but continue — best effort cleanup
}

scope := s.refToScope[ref]
if scopeRefs, ok := s.scopeToRefs[scope]; ok {
delete(scopeRefs, ref)
if len(scopeRefs) == 0 {
delete(s.scopeToRefs, scope)
}
}

delete(s.refs, ref)
delete(s.refToScope, ref)
removed++
}
}

return removed
}

// Start begins the background cleanup goroutine if cleanup is enabled.
func (s *FileMediaStore) Start() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Start function need match the Stop function also using s.once.Do to dispatch goroutine.

if !s.cleanerCfg.Enabled || s.stop == nil {
return
}

log.Printf("[media] cleanup enabled: interval=%s, max_age=%s",
s.cleanerCfg.Interval, s.cleanerCfg.MaxAge)

go func() {
ticker := time.NewTicker(s.cleanerCfg.Interval)
defer ticker.Stop()

for {
select {
case <-ticker.C:
if n := s.CleanExpired(); n > 0 {
log.Printf("[media] cleanup: removed %d expired entries", n)
}
case <-s.stop:
return
}
}
}()
}

// Stop terminates the background cleanup goroutine.
func (s *FileMediaStore) Stop() {
if s.stop == nil {
return
}
s.once.Do(func() {
close(s.stop)
})
}
Loading