picoclaw/pkg/state/state.go
dj-oyu d8b0f26b79 refactor: replace moderate-diff Go files with upstream, add fork extensions
Replace ~30 more Go source files with upstream versions and re-add
fork-only functionality via appended code or _ext.go files.

Key files aligned: providers/types.go, state/state.go, tools/base.go,
tools/registry.go, channels/*, config/defaults.go, providers/*.

Conflict metrics: 105 → 34 files, 646 → 315 markers (-51%)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:48:58 +09:00

219 lines
6 KiB
Go

package state
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// State represents the persistent state for a workspace.
// It includes information about the last active channel/chat.
type State struct {
// LastChannel is the last channel used for communication
LastChannel string `json:"last_channel,omitempty"`
// LastHeartbeatTarget is the last destination considered safe for heartbeat delivery.
// Format: "channel:chatID[/thread]".
LastHeartbeatTarget string `json:"last_heartbeat_target,omitempty"`
// HeartbeatTarget is an explicit heartbeat destination override.
// Format: "channel:chatID[/thread]".
HeartbeatTarget string `json:"heartbeat_target,omitempty"`
// LastChatID is the last chat ID used for communication
LastChatID string `json:"last_chat_id,omitempty"`
// Timestamp is the last time this state was updated
Timestamp time.Time `json:"timestamp"`
}
// Manager manages persistent state with atomic saves.
type Manager struct {
workspace string
state *State
mu sync.RWMutex
stateFile string
}
// NewManager creates a new state manager for the given workspace.
func NewManager(workspace string) *Manager {
stateDir := filepath.Join(workspace, "state")
stateFile := filepath.Join(stateDir, "state.json")
oldStateFile := filepath.Join(workspace, "state.json")
// Create state directory if it doesn't exist
if err := os.MkdirAll(stateDir, 0o700); err != nil {
log.Printf("[WARN] state: failed to create state directory %s: %v", stateDir, err)
}
sm := &Manager{
workspace: workspace,
stateFile: stateFile,
state: &State{},
}
// Try to load from new location first
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
// New file doesn't exist, try migrating from old location
if data, err := os.ReadFile(oldStateFile); err == nil {
if err := json.Unmarshal(data, sm.state); err == nil {
// Migrate to new location
if err := sm.saveAtomic(); err != nil {
log.Printf("[WARN] state: failed to save state: %v", err)
}
log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile)
}
}
} else {
// Load from new location
if err := sm.load(); err != nil {
log.Printf("[WARN] state: failed to load state: %v", err)
}
}
return sm
}
// SetLastChannel atomically updates the last channel and saves the state.
// This method uses a temp file + rename pattern for atomic writes,
// ensuring that the state file is never corrupted even if the process crashes.
func (sm *Manager) SetLastChannel(channel string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
// Update state
sm.state.LastChannel = channel
sm.state.Timestamp = time.Now()
// Atomic save using temp file + rename
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// SetLastChatID atomically updates the last chat ID and saves the state.
func (sm *Manager) SetLastChatID(chatID string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
// Update state
sm.state.LastChatID = chatID
sm.state.Timestamp = time.Now()
// Atomic save using temp file + rename
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// GetLastChannel returns the last channel from the state.
func (sm *Manager) GetLastChannel() string {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.LastChannel
}
// GetLastChatID returns the last chat ID from the state.
func (sm *Manager) GetLastChatID() string {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.LastChatID
}
// GetTimestamp returns the timestamp of the last state update.
func (sm *Manager) GetTimestamp() time.Time {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.Timestamp
}
// saveAtomic performs an atomic save using temp file + rename.
// This ensures that the state file is never corrupted:
// 1. Write to a temp file
// 2. Sync to disk (critical for SD cards/flash storage)
// 3. Rename temp file to target (atomic on POSIX systems)
// 4. If rename fails, cleanup the temp file
//
// Must be called with the lock held.
func (sm *Manager) saveAtomic() error {
// Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
data, err := json.MarshalIndent(sm.state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
return fileutil.WriteFileAtomic(sm.stateFile, data, 0o600)
}
// load loads the state from disk.
func (sm *Manager) load() error {
data, err := os.ReadFile(sm.stateFile)
if err != nil {
// File doesn't exist yet, that's OK
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read state file: %w", err)
}
if err := json.Unmarshal(data, sm.state); err != nil {
return fmt.Errorf("failed to unmarshal state: %w", err)
}
return nil
}
// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state.
func (sm *Manager) SetLastHeartbeatTarget(target string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.state.LastHeartbeatTarget = target
sm.state.Timestamp = time.Now()
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// SetHeartbeatTarget atomically updates the explicit heartbeat target and saves the state.
func (sm *Manager) SetHeartbeatTarget(target string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.state.HeartbeatTarget = target
sm.state.Timestamp = time.Now()
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// GetLastHeartbeatTarget returns the last heartbeat target from the state.
func (sm *Manager) GetLastHeartbeatTarget() string {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.LastHeartbeatTarget
}
// GetHeartbeatTarget returns the explicit heartbeat target from the state.
func (sm *Manager) GetHeartbeatTarget() string {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.state.HeartbeatTarget
}