Files
dingtalk-workspace-cli/internal/cache/store.go
T
2026-03-28 17:51:22 +08:00

326 lines
9.3 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cache
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// HasActionVersionChanged compares cached actionVersion strings against the
// versions reported by a fresh Detail API response. It returns true when at
// least one tool's version has changed, signalling that the tools cache
// should be refreshed even if the TTL has not expired.
func HasActionVersionChanged(cached map[string]string, detailTools []market.DetailTool) bool {
if len(cached) == 0 {
return false // no prior version data → not a change
}
for _, tool := range detailTools {
name := strings.TrimSpace(tool.ToolName)
version := strings.TrimSpace(tool.ActionVersion)
if name == "" || version == "" {
continue
}
if oldVersion, exists := cached[name]; exists && oldVersion != version {
return true
}
}
return false
}
// ExtractActionVersions builds a tool-name → actionVersion map from detail tools.
func ExtractActionVersions(detailTools []market.DetailTool) map[string]string {
if len(detailTools) == 0 {
return nil
}
versions := make(map[string]string, len(detailTools))
for _, tool := range detailTools {
name := strings.TrimSpace(tool.ToolName)
version := strings.TrimSpace(tool.ActionVersion)
if name != "" && version != "" {
versions[name] = version
}
}
if len(versions) == 0 {
return nil
}
return versions
}
const (
RegistryTTL = 24 * time.Hour
ToolsTTL = 7 * 24 * time.Hour
DetailTTL = 7 * 24 * time.Hour
RevalidateAfter = 1 * time.Hour
)
type Freshness string
const (
FreshnessFresh Freshness = "fresh"
FreshnessStale Freshness = "stale"
)
type Store struct {
Root string
Now func() time.Time
}
type RegistrySnapshot struct {
SavedAt time.Time `json:"saved_at"`
Servers []market.ServerDescriptor `json:"servers"`
}
type ToolsSnapshot struct {
SavedAt time.Time `json:"saved_at"`
ServerKey string `json:"server_key"`
ProtocolVersion string `json:"protocol_version"`
Tools []transport.ToolDescriptor `json:"tools"`
ActionVersions map[string]string `json:"action_versions,omitempty"`
}
type DetailSnapshot struct {
SavedAt time.Time `json:"saved_at"`
MCPID int `json:"mcp_id"`
Payload json.RawMessage `json:"payload"`
}
func NewStore(root string) *Store {
if strings.TrimSpace(root) == "" {
root = defaultCacheRoot()
}
return &Store{
Root: root,
Now: time.Now,
}
}
// defaultCacheRoot returns a stable, persistent cache directory.
// Prefers ~/.dws/cache (matches defaultConfigDir in app/config.go).
// Falls back to os.TempDir()/dws-cache only when $HOME is unavailable.
func defaultCacheRoot() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".dws", "cache")
}
return filepath.Join(os.TempDir(), "dws-cache")
}
func (s *Store) SaveRegistry(partition string, snapshot RegistrySnapshot) error {
if snapshot.SavedAt.IsZero() {
snapshot.SavedAt = s.Now().UTC()
}
return s.saveJSON(s.registryPath(partition), snapshot)
}
func (s *Store) LoadRegistry(partition string) (RegistrySnapshot, Freshness, error) {
var snapshot RegistrySnapshot
if err := s.loadJSON(s.registryPath(partition), &snapshot); err != nil {
return RegistrySnapshot{}, "", err
}
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, RegistryTTL), nil
}
func (s *Store) SaveTools(partition, serverKey string, snapshot ToolsSnapshot) error {
if snapshot.SavedAt.IsZero() {
snapshot.SavedAt = s.Now().UTC()
}
return s.saveJSON(s.toolsPath(partition, serverKey), snapshot)
}
func (s *Store) LoadTools(partition, serverKey string) (ToolsSnapshot, Freshness, error) {
var snapshot ToolsSnapshot
if err := s.loadJSON(s.toolsPath(partition, serverKey), &snapshot); err != nil {
return ToolsSnapshot{}, "", err
}
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, ToolsTTL), nil
}
// DeleteTools removes the cached tools snapshot for a server, forcing a
// re-fetch on the next DiscoverServerRuntime call.
func (s *Store) DeleteTools(partition, serverKey string) error {
path := s.toolsPath(partition, serverKey)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// ToolsCacheEntrySummary summarises one cached tools snapshot.
type ToolsCacheEntrySummary struct {
ServerKey string `json:"server_key"`
Freshness Freshness `json:"freshness"`
SavedAt time.Time `json:"saved_at"`
ToolCount int `json:"tool_count"`
TTLRemaining string `json:"ttl_remaining"`
}
// ListToolsCacheEntries walks the cache directory and returns a summary for
// each server whose tools snapshot is cached.
func (s *Store) ListToolsCacheEntries(partition string) ([]ToolsCacheEntrySummary, error) {
toolsDir := filepath.Join(s.Root, sanitize(partition), "tools")
entries, err := os.ReadDir(toolsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
now := s.Now().UTC()
summaries := make([]ToolsCacheEntrySummary, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
var snapshot ToolsSnapshot
path := filepath.Join(toolsDir, entry.Name())
if loadErr := s.loadJSON(path, &snapshot); loadErr != nil {
continue
}
f := freshness(now, snapshot.SavedAt, ToolsTTL)
remaining := ""
if f == FreshnessFresh {
rem := ToolsTTL - now.Sub(snapshot.SavedAt)
if rem > 0 {
remaining = rem.Truncate(time.Minute).String()
}
}
summaries = append(summaries, ToolsCacheEntrySummary{
ServerKey: snapshot.ServerKey,
Freshness: f,
SavedAt: snapshot.SavedAt,
ToolCount: len(snapshot.Tools),
TTLRemaining: remaining,
})
}
return summaries, nil
}
func (s *Store) SaveDetail(partition, serverKey string, snapshot DetailSnapshot) error {
if snapshot.SavedAt.IsZero() {
snapshot.SavedAt = s.Now().UTC()
}
return s.saveJSON(s.detailPath(partition, serverKey), snapshot)
}
func (s *Store) LoadDetail(partition, serverKey string) (DetailSnapshot, Freshness, error) {
var snapshot DetailSnapshot
if err := s.loadJSON(s.detailPath(partition, serverKey), &snapshot); err != nil {
return DetailSnapshot{}, "", err
}
return snapshot, freshness(s.Now().UTC(), snapshot.SavedAt, DetailTTL), nil
}
// DeleteDetail removes the cached detail snapshot for a server.
func (s *Store) DeleteDetail(partition, serverKey string) error {
path := s.detailPath(partition, serverKey)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (s *Store) registryPath(partition string) string {
return filepath.Join(s.Root, sanitize(partition), "market", "servers.json")
}
func (s *Store) toolsPath(partition, serverKey string) string {
return filepath.Join(s.Root, sanitize(partition), "tools", sanitize(serverKey)+".json")
}
func (s *Store) detailPath(partition, serverKey string) string {
return filepath.Join(s.Root, sanitize(partition), "detail", sanitize(serverKey)+".json")
}
func (s *Store) saveJSON(path string, value any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
// Atomic write with fsync to ensure data durability
tmpPath := path + ".tmp"
tmpFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
writeSuccess := false
defer func() {
if !writeSuccess {
tmpFile.Close()
_ = os.Remove(tmpPath)
}
}()
if _, err := tmpFile.Write(data); err != nil {
return err
}
if err := tmpFile.Sync(); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return err
}
writeSuccess = true
return nil
}
func (s *Store) loadJSON(path string, out any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, out)
}
func freshness(now, savedAt time.Time, ttl time.Duration) Freshness {
if savedAt.IsZero() || now.Sub(savedAt) > ttl {
return FreshnessStale
}
return FreshnessFresh
}
// ShouldRevalidate reports whether a still-valid snapshot is old enough to
// merit a live revalidation attempt before trusting it as the current truth.
func ShouldRevalidate(now, savedAt time.Time) bool {
if savedAt.IsZero() {
return true
}
return now.Sub(savedAt) >= RevalidateAfter
}
func sanitize(value string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
return replacer.Replace(value)
}
func IsNotExist(err error) bool {
return errors.Is(err, os.ErrNotExist)
}