mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(pkg/utils): add unified atomic file write utility
This commit is contained in:
+10
-3
@@ -12,6 +12,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// MemoryStore manages persistent memory for the agent.
|
||||
@@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
||||
|
||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||
return utils.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// ReadToday reads today's daily note.
|
||||
@@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||
|
||||
// Ensure month directory exists
|
||||
monthDir := filepath.Dir(todayFile)
|
||||
os.MkdirAll(monthDir, 0o755)
|
||||
if err := os.MkdirAll(monthDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var existingContent string
|
||||
if data, err := os.ReadFile(todayFile); err == nil {
|
||||
@@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
||||
newContent = existingContent + "\n" + content
|
||||
}
|
||||
|
||||
return os.WriteFile(todayFile, []byte(newContent), 0o644)
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
return utils.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
|
||||
}
|
||||
|
||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||
|
||||
+5
-6
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
type AuthCredential struct {
|
||||
@@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
|
||||
|
||||
func SaveStore(store *AuthStore) error {
|
||||
path := authFilePath()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
return utils.WriteFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
func GetCredential(provider string) (*AuthCredential, error) {
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// rrCounter is a global counter for round-robin load balancing across models.
|
||||
@@ -526,12 +526,8 @@ func SaveConfig(path string, cfg *Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
return utils.WriteFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *Config) WorkspacePath() string {
|
||||
|
||||
+3
-7
@@ -7,11 +7,11 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/adhocore/gronx"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
type CronSchedule struct {
|
||||
@@ -330,17 +330,13 @@ func (cs *CronService) loadStore() error {
|
||||
}
|
||||
|
||||
func (cs *CronService) saveStoreUnsafe() error {
|
||||
dir := filepath.Dir(cs.storePath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cs.store, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(cs.storePath, data, 0o600)
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
return utils.WriteFileAtomic(cs.storePath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cs *CronService) AddJob(
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -275,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically.
|
||||
Add your heartbeat tasks below this line:
|
||||
`
|
||||
|
||||
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
|
||||
if err := utils.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
|
||||
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
|
||||
} else {
|
||||
hs.logInfo("Created default HEARTBEAT.md template")
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
type SkillInstaller struct {
|
||||
@@ -64,7 +66,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, body, 0o644); err != nil {
|
||||
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
if err := utils.WriteFileAtomic(skillPath, body, 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write skill file: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+8
-19
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// State represents the persistent state for a workspace.
|
||||
@@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time {
|
||||
// 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. Rename temp file to target (atomic on POSIX systems)
|
||||
// 3. If rename fails, cleanup the 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 {
|
||||
// Create temp file in the same directory as the target
|
||||
tempFile := sm.stateFile + ".tmp"
|
||||
|
||||
// Marshal state to JSON
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
if err := os.WriteFile(tempFile, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename from temp to target
|
||||
if err := os.Rename(tempFile, sm.stateFile); err != nil {
|
||||
// Cleanup temp file if rename fails
|
||||
os.Remove(tempFile)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return utils.WriteFileAtomic(sm.stateFile, data, 0o600)
|
||||
}
|
||||
|
||||
// load loads the state from disk.
|
||||
|
||||
+31
-26
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// validatePath ensures the given path is within the workspace if restrict is true.
|
||||
@@ -276,25 +278,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
|
||||
}
|
||||
|
||||
func (h *hostFs) WriteFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directories: %w", err)
|
||||
}
|
||||
|
||||
// We use a "write-then-rename" pattern here to ensure an atomic write.
|
||||
// This prevents the target file from being left in a truncated or partial state
|
||||
// if the operation is interrupted, as the rename operation is atomic on Linux.
|
||||
tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
|
||||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
||||
os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to replace original file: %w", err)
|
||||
}
|
||||
return nil
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||
return utils.WriteFileAtomic(path, data, 0o600)
|
||||
}
|
||||
|
||||
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
|
||||
@@ -351,14 +337,33 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// We use a "write-then-rename" pattern here to ensure an atomic write.
|
||||
// This prevents the target file from being left in a truncated or partial state
|
||||
// if the operation is interrupted, as the rename operation is atomic on Linux.
|
||||
tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
|
||||
// Use atomic write pattern with explicit sync for flash storage reliability.
|
||||
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||
tmpRelPath := fmt.Sprintf(".tmp-%d.tmp", time.Now().UnixNano())
|
||||
|
||||
if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
|
||||
root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
|
||||
return fmt.Errorf("failed to write to temp file: %w", err)
|
||||
tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
root.Remove(tmpRelPath)
|
||||
return fmt.Errorf("failed to open temp file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
root.Remove(tmpRelPath)
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
// CRITICAL: Force sync to storage medium before rename.
|
||||
// This ensures data is physically written to disk, not just cached.
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
tmpFile.Close()
|
||||
root.Remove(tmpRelPath)
|
||||
return fmt.Errorf("failed to sync temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
root.Remove(tmpRelPath)
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := root.Rename(tmpRelPath, relPath); err != nil {
|
||||
|
||||
@@ -197,5 +197,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644)
|
||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||
return utils.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.
|
||||
//
|
||||
// This guarantees that the target file is either:
|
||||
// - Completely written with the new data
|
||||
// - Unchanged (if write fails or power loss during write)
|
||||
//
|
||||
// The function:
|
||||
// 1. Creates a temp file in the same directory
|
||||
// 2. Writes data to temp file
|
||||
// 3. Syncs to disk (critical for SD cards/flash storage)
|
||||
// 4. Sets file permissions
|
||||
// 5. Atomically renames temp file to target path
|
||||
//
|
||||
// Parameters:
|
||||
// - path: Target file path
|
||||
// - data: Data to write
|
||||
// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)
|
||||
//
|
||||
// Returns:
|
||||
// - Error if any step fails, nil on success
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Secure config file (owner read/write only)
|
||||
// err := utils.WriteFileAtomic("config.json", data, 0o600)
|
||||
//
|
||||
// // Public readable file
|
||||
// err := utils.WriteFileAtomic("public.txt", data, 0o644)
|
||||
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// Create temp file in the same directory (ensures atomic rename works)
|
||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
// Cleanup on error: ensure temp file is removed if anything fails
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Write data to temp file
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
// CRITICAL: Force sync to storage medium before rename.
|
||||
// This ensures data is physically written to disk, not just cached.
|
||||
// Essential for SD cards, eMMC, and other flash storage on edge devices.
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to sync temp file: %w", err)
|
||||
}
|
||||
|
||||
// Set file permissions
|
||||
if err := tmpFile.Chmod(perm); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to set permissions: %w", err)
|
||||
}
|
||||
|
||||
// Close file before rename
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename: temp file becomes the target
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
// Success: skip cleanup
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user